mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
4 Commits
6c9e712529
...
811e4d5963
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
811e4d5963 | ||
|
|
f448b259a2 | ||
|
|
ef9d7e07b7 | ||
|
|
220d09c068 |
@ -136,90 +136,32 @@ ffmpeg:
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</ConfigTabs>
|
</ConfigTabs>
|
||||||
|
|
||||||
### Configuring Intel GPU Stats in Docker
|
### Configuring Intel GPU Stats
|
||||||
|
|
||||||
Additional configuration is needed for the Docker container to be able to access the `intel_gpu_top` command for GPU stats. There are two options:
|
Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc/<pid>/fdinfo/<fd>`. This requires:
|
||||||
|
|
||||||
1. Run the container as privileged.
|
- Linux kernel **5.19 or newer** for the `i915` driver, or any release of the `xe` driver.
|
||||||
2. Add the `CAP_PERFMON` capability (note: you might need to set the `perf_event_paranoid` low enough to allow access to the performance event system.)
|
- Frigate running with permission to read other processes' fdinfo. Running as root inside the container (the default) satisfies this; non-root setups may need `CAP_SYS_PTRACE`.
|
||||||
|
|
||||||
#### Run as privileged
|
No `intel_gpu_top` binary, `CAP_PERFMON`, privileged mode, or `perf_event_paranoid` tuning is required.
|
||||||
|
|
||||||
This method works, but it gives more permissions to the container than are actually needed.
|
#### Stats for SR-IOV or specific devices
|
||||||
|
|
||||||
##### Docker Compose - Privileged
|
If the host has more than one Intel GPU (e.g. an iGPU plus a discrete GPU, or SR-IOV virtual functions), pin stats collection to a specific device by setting `intel_gpu_device` to either its PCI bus address or a DRM card/render-node path:
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
frigate:
|
|
||||||
...
|
|
||||||
image: ghcr.io/blakeblackshear/frigate:stable
|
|
||||||
# highlight-next-line
|
|
||||||
privileged: true
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Docker Run CLI - Privileged
|
|
||||||
|
|
||||||
```bash {4}
|
|
||||||
docker run -d \
|
|
||||||
--name frigate \
|
|
||||||
...
|
|
||||||
--privileged \
|
|
||||||
ghcr.io/blakeblackshear/frigate:stable
|
|
||||||
```
|
|
||||||
|
|
||||||
#### CAP_PERFMON
|
|
||||||
|
|
||||||
Only recent versions of Docker support the `CAP_PERFMON` capability. You can test to see if yours supports it by running: `docker run --cap-add=CAP_PERFMON hello-world`
|
|
||||||
|
|
||||||
##### Docker Compose - CAP_PERFMON
|
|
||||||
|
|
||||||
```yaml {5,6}
|
|
||||||
services:
|
|
||||||
frigate:
|
|
||||||
...
|
|
||||||
image: ghcr.io/blakeblackshear/frigate:stable
|
|
||||||
cap_add:
|
|
||||||
- CAP_PERFMON
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Docker Run CLI - CAP_PERFMON
|
|
||||||
|
|
||||||
```bash {4}
|
|
||||||
docker run -d \
|
|
||||||
--name frigate \
|
|
||||||
...
|
|
||||||
--cap-add=CAP_PERFMON \
|
|
||||||
ghcr.io/blakeblackshear/frigate:stable
|
|
||||||
```
|
|
||||||
|
|
||||||
#### perf_event_paranoid
|
|
||||||
|
|
||||||
_Note: This setting must be changed for the entire system._
|
|
||||||
|
|
||||||
For more information on the various values across different distributions, see https://askubuntu.com/questions/1400874/what-does-perf-paranoia-level-four-do.
|
|
||||||
|
|
||||||
Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=2 >> /etc/sysctl.d/local.conf'`
|
|
||||||
|
|
||||||
#### Stats for SR-IOV or other devices
|
|
||||||
|
|
||||||
When using virtualized GPUs via SR-IOV, you need to specify the device path to use to gather stats from `intel_gpu_top`. This example may work for some systems using SR-IOV:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
telemetry:
|
telemetry:
|
||||||
stats:
|
stats:
|
||||||
intel_gpu_device: "sriov"
|
intel_gpu_device: "0000:00:02.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
For other virtualized GPUs, try specifying the direct path to the device instead:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
telemetry:
|
telemetry:
|
||||||
stats:
|
stats:
|
||||||
intel_gpu_device: "drm:/dev/dri/card0"
|
intel_gpu_device: "/dev/dri/card1"
|
||||||
```
|
```
|
||||||
|
|
||||||
If you are passing in a device path, make sure you've passed the device through to the container.
|
When passing a device path, make sure the device is also passed through to the container.
|
||||||
|
|
||||||
## AMD-based CPUs
|
## AMD-based CPUs
|
||||||
|
|
||||||
|
|||||||
@ -25,8 +25,8 @@ class StatsConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
intel_gpu_device: Optional[str] = Field(
|
intel_gpu_device: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
title="SR-IOV device",
|
title="Intel GPU device",
|
||||||
description="Device identifier used when treating Intel GPUs as SR-IOV to fix GPU stats.",
|
description="PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,6 @@ from frigate.util.services import get_amd_gpu_stats, get_intel_gpu_stats
|
|||||||
class TestGpuStats(unittest.TestCase):
|
class TestGpuStats(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.amd_results = "Unknown Radeon card. <= R500 won't work, new cards might.\nDumping to -, line limit 1.\n1664070990.607556: bus 10, gpu 4.17%, ee 0.00%, vgt 0.00%, ta 0.00%, tc 0.00%, sx 0.00%, sh 0.00%, spi 0.83%, smx 0.00%, cr 0.00%, sc 0.00%, pa 0.00%, db 0.00%, cb 0.00%, vram 60.37% 294.04mb, gtt 0.33% 52.21mb, mclk 100.00% 1.800ghz, sclk 26.65% 0.533ghz\n"
|
self.amd_results = "Unknown Radeon card. <= R500 won't work, new cards might.\nDumping to -, line limit 1.\n1664070990.607556: bus 10, gpu 4.17%, ee 0.00%, vgt 0.00%, ta 0.00%, tc 0.00%, sx 0.00%, sh 0.00%, spi 0.83%, smx 0.00%, cr 0.00%, sc 0.00%, pa 0.00%, db 0.00%, cb 0.00%, vram 60.37% 294.04mb, gtt 0.33% 52.21mb, mclk 100.00% 1.800ghz, sclk 26.65% 0.533ghz\n"
|
||||||
self.intel_results = """{"period":{"duration":1.194033,"unit":"ms"},"frequency":{"requested":0.000000,"actual":0.000000,"unit":"MHz"},"interrupts":{"count":3349.991164,"unit":"irq/s"},"rc6":{"value":47.844741,"unit":"%"},"engines":{"Render/3D/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Blitter/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/0":{"busy":4.533124,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/1":{"busy":6.194385,"sema":0.000000,"wait":0.000000,"unit":"%"},"VideoEnhance/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"}}},{"period":{"duration":1.189291,"unit":"ms"},"frequency":{"requested":0.000000,"actual":0.000000,"unit":"MHz"},"interrupts":{"count":0.000000,"unit":"irq/s"},"rc6":{"value":100.000000,"unit":"%"},"engines":{"Render/3D/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Blitter/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/1":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"VideoEnhance/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"}}}"""
|
|
||||||
self.nvidia_results = "name, utilization.gpu [%], memory.used [MiB], memory.total [MiB]\nNVIDIA GeForce RTX 3050, 42 %, 5036 MiB, 8192 MiB\n"
|
|
||||||
|
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
def test_amd_gpu_stats(self, sp):
|
def test_amd_gpu_stats(self, sp):
|
||||||
@ -19,32 +17,76 @@ 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("subprocess.run")
|
@patch("frigate.util.services.time.sleep")
|
||||||
# def test_nvidia_gpu_stats(self, sp):
|
@patch("frigate.util.services.time.monotonic")
|
||||||
# process = MagicMock()
|
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||||
# process.returncode = 0
|
def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep):
|
||||||
# process.stdout = self.nvidia_results
|
# 1 second of wall clock between snapshots
|
||||||
# sp.return_value = process
|
monotonic.side_effect = [0.0, 1.0]
|
||||||
# nvidia_stats = get_nvidia_gpu_stats()
|
|
||||||
# assert nvidia_stats == {
|
|
||||||
# "name": "NVIDIA GeForce RTX 3050",
|
|
||||||
# "gpu": "42 %",
|
|
||||||
# "mem": "61.5 %",
|
|
||||||
# }
|
|
||||||
|
|
||||||
@patch("subprocess.run")
|
# Two i915 clients on the same iGPU. Engine values are cumulative ns.
|
||||||
def test_intel_gpu_stats(self, sp):
|
# Deltas over the 1s window:
|
||||||
process = MagicMock()
|
# client A (pid 100): render +200_000_000 (20%), video +500_000_000 (50%),
|
||||||
process.returncode = 124
|
# video-enhance +100_000_000 (10%)
|
||||||
process.stdout = self.intel_results
|
# client B (pid 200): compute +100_000_000 (10%)
|
||||||
sp.return_value = process
|
# Engine totals → render 20, video 50, video-enhance 10, compute 10
|
||||||
intel_stats = get_intel_gpu_stats(False)
|
# → compute = render + compute = 30
|
||||||
# rc6 values: 47.844741 and 100.0 → avg 73.92 → gpu = 100 - 73.92 = 26.08%
|
# → dec = video + video-enhance = 60
|
||||||
# Render/3D/0: 0.0 and 0.0 → enc = 0.0%
|
# → gpu = compute + dec = 90
|
||||||
# Video/0: 4.533124 and 0.0 → dec = 2.27%
|
snapshot_a = {
|
||||||
assert intel_stats == {
|
("0000:00:02.0", "1", "100"): {
|
||||||
"gpu": "26.08%",
|
"driver": "i915",
|
||||||
"mem": "-%",
|
"pid": "100",
|
||||||
"compute": "0.0%",
|
"engines": {
|
||||||
"dec": "2.27%",
|
"render": (1_000_000_000, 0),
|
||||||
|
"video": (5_000_000_000, 0),
|
||||||
|
"video-enhance": (200_000_000, 0),
|
||||||
|
"compute": (0, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
("0000:00:02.0", "2", "200"): {
|
||||||
|
"driver": "i915",
|
||||||
|
"pid": "200",
|
||||||
|
"engines": {
|
||||||
|
"render": (0, 0),
|
||||||
|
"compute": (2_000_000_000, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
snapshot_b = {
|
||||||
|
("0000:00:02.0", "1", "100"): {
|
||||||
|
"driver": "i915",
|
||||||
|
"pid": "100",
|
||||||
|
"engines": {
|
||||||
|
"render": (1_200_000_000, 0),
|
||||||
|
"video": (5_500_000_000, 0),
|
||||||
|
"video-enhance": (300_000_000, 0),
|
||||||
|
"compute": (0, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
("0000:00:02.0", "2", "200"): {
|
||||||
|
"driver": "i915",
|
||||||
|
"pid": "200",
|
||||||
|
"engines": {
|
||||||
|
"render": (0, 0),
|
||||||
|
"compute": (2_100_000_000, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
read_fdinfo.side_effect = [snapshot_a, snapshot_b]
|
||||||
|
|
||||||
|
intel_stats = get_intel_gpu_stats(None)
|
||||||
|
|
||||||
|
sleep.assert_called_once()
|
||||||
|
assert intel_stats == {
|
||||||
|
"gpu": "90.0%",
|
||||||
|
"mem": "-%",
|
||||||
|
"compute": "30.0%",
|
||||||
|
"dec": "60.0%",
|
||||||
|
"clients": {"100": "80.0%", "200": "10.0%"},
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||||
|
def test_intel_gpu_stats_no_clients(self, read_fdinfo):
|
||||||
|
read_fdinfo.return_value = {}
|
||||||
|
assert get_intel_gpu_stats(None) is None
|
||||||
|
|||||||
@ -264,156 +264,214 @@ def get_amd_gpu_stats() -> Optional[dict[str, str]]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, str]]:
|
_INTEL_FDINFO_SAMPLE_SECONDS = 1.0
|
||||||
"""Get stats using intel_gpu_top.
|
|
||||||
|
|
||||||
Returns overall GPU usage derived from rc6 residency (idle time),
|
# Engines we track. Render/3D and Compute are pooled into "compute"; Video and
|
||||||
plus individual engine breakdowns:
|
# VideoEnhance into "dec" (VideoEnhance is the post-process engine that handles
|
||||||
- enc: Render/3D engine (compute/shader encoder, used by QSV)
|
# VAAPI scaling/deinterlace/CSC, e.g. ffmpeg `-vf scale_vaapi=...`). The Copy
|
||||||
- dec: Video engines (fixed-function codec, used by VAAPI)
|
# (DMA blitter) engine is intentionally ignored — it represents transparent
|
||||||
|
# memory transfers, not user-visible GPU work.
|
||||||
|
# i915 fdinfo keys (cumulative ns) → logical engine name.
|
||||||
|
_I915_ENGINE_KEYS = {
|
||||||
|
"drm-engine-render": "render",
|
||||||
|
"drm-engine-video": "video",
|
||||||
|
"drm-engine-video-enhance": "video-enhance",
|
||||||
|
"drm-engine-compute": "compute",
|
||||||
|
}
|
||||||
|
# Xe fdinfo suffixes (cumulative cycles, paired with drm-total-cycles-*).
|
||||||
|
_XE_ENGINE_KEYS = {
|
||||||
|
"rcs": "render",
|
||||||
|
"vcs": "video",
|
||||||
|
"vecs": "video-enhance",
|
||||||
|
"ccs": "compute",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_intel_gpu_pdev(device: Optional[str]) -> Optional[str]:
|
||||||
|
"""Map a configured GPU hint (/dev/dri/card1, renderD128, or a PCI bus
|
||||||
|
address) to its drm-pdev string so we can filter fdinfo entries to that
|
||||||
|
device. Returns None when no hint is supplied or it cannot be resolved."""
|
||||||
|
if not device:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if re.match(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$", device):
|
||||||
|
return device
|
||||||
|
|
||||||
|
name = os.path.basename(device.rstrip("/"))
|
||||||
|
try:
|
||||||
|
return os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device"))
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_intel_drm_fdinfo(target_pdev: Optional[str]) -> dict:
|
||||||
|
"""Snapshot DRM fdinfo for every Intel client visible in /proc.
|
||||||
|
|
||||||
|
Returns a dict keyed by (pdev, drm-client-id, pid) so the same context
|
||||||
|
seen via multiple file descriptors on a single process collapses to one
|
||||||
|
entry.
|
||||||
"""
|
"""
|
||||||
|
snapshot: dict = {}
|
||||||
def get_stats_manually(output: str) -> dict[str, str]:
|
|
||||||
"""Find global stats via regex when json fails to parse."""
|
|
||||||
reading = "".join(output)
|
|
||||||
results: dict[str, str] = {}
|
|
||||||
|
|
||||||
# rc6 residency for overall GPU usage
|
|
||||||
rc6_match = re.search(r'"rc6":\{"value":([\d.]+)', reading)
|
|
||||||
if rc6_match:
|
|
||||||
rc6_value = float(rc6_match.group(1))
|
|
||||||
results["gpu"] = f"{round(100.0 - rc6_value, 2)}%"
|
|
||||||
else:
|
|
||||||
results["gpu"] = "-%"
|
|
||||||
|
|
||||||
results["mem"] = "-%"
|
|
||||||
|
|
||||||
# Render/3D is the compute/encode engine
|
|
||||||
render = []
|
|
||||||
for result in re.findall(r'"Render/3D/0":{[a-z":\d.,%]+}', reading):
|
|
||||||
packet = json.loads(result[14:])
|
|
||||||
single = packet.get("busy", 0.0)
|
|
||||||
render.append(float(single))
|
|
||||||
|
|
||||||
if render:
|
|
||||||
results["compute"] = f"{round(sum(render) / len(render), 2)}%"
|
|
||||||
|
|
||||||
# Video engines are the fixed-function decode engines
|
|
||||||
video = []
|
|
||||||
for result in re.findall(r'"Video/\d":{[a-z":\d.,%]+}', reading):
|
|
||||||
packet = json.loads(result[10:])
|
|
||||||
single = packet.get("busy", 0.0)
|
|
||||||
video.append(float(single))
|
|
||||||
|
|
||||||
if video:
|
|
||||||
results["dec"] = f"{round(sum(video) / len(video), 2)}%"
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
intel_gpu_top_command = [
|
|
||||||
"timeout",
|
|
||||||
"0.5s",
|
|
||||||
"intel_gpu_top",
|
|
||||||
"-J",
|
|
||||||
"-o",
|
|
||||||
"-",
|
|
||||||
"-s",
|
|
||||||
"1000", # Intel changed this from seconds to milliseconds in 2024+ versions
|
|
||||||
]
|
|
||||||
|
|
||||||
if intel_gpu_device:
|
|
||||||
intel_gpu_top_command += ["-d", intel_gpu_device]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
p = sp.run(
|
proc_entries = os.listdir("/proc")
|
||||||
intel_gpu_top_command,
|
except OSError:
|
||||||
encoding="ascii",
|
return snapshot
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# timeout has a non-zero returncode when timeout is reached
|
for entry in proc_entries:
|
||||||
if p.returncode != 124:
|
if not entry.isdigit():
|
||||||
logger.error(f"Unable to poll intel GPU stats: {p.stderr}")
|
continue
|
||||||
return None
|
|
||||||
else:
|
|
||||||
output = "".join(p.stdout.split())
|
|
||||||
|
|
||||||
|
fdinfo_dir = f"/proc/{entry}/fdinfo"
|
||||||
try:
|
try:
|
||||||
data = json.loads(f"[{output}]")
|
fds = os.listdir(fdinfo_dir)
|
||||||
except json.JSONDecodeError:
|
except (FileNotFoundError, PermissionError, NotADirectoryError, OSError):
|
||||||
return get_stats_manually(output)
|
continue
|
||||||
|
|
||||||
results: dict[str, str] = {}
|
for fd in fds:
|
||||||
rc6_values = []
|
try:
|
||||||
render_global = []
|
with open(f"{fdinfo_dir}/{fd}") as f:
|
||||||
video_global = []
|
content = f.read()
|
||||||
# per-client: {pid: [total_busy_per_sample, ...]}
|
except (FileNotFoundError, PermissionError, OSError):
|
||||||
client_usages: dict[str, list[float]] = {}
|
continue
|
||||||
|
|
||||||
for block in data:
|
if "drm-driver" not in content:
|
||||||
# rc6 residency: percentage of time GPU is idle
|
continue
|
||||||
rc6 = block.get("rc6", {}).get("value")
|
|
||||||
if rc6 is not None:
|
|
||||||
rc6_values.append(float(rc6))
|
|
||||||
|
|
||||||
global_engine = block.get("engines")
|
fields: dict[str, str] = {}
|
||||||
|
for line in content.splitlines():
|
||||||
|
key, sep, value = line.partition(":")
|
||||||
|
if sep:
|
||||||
|
fields[key.strip()] = value.strip()
|
||||||
|
|
||||||
if global_engine:
|
driver = fields.get("drm-driver")
|
||||||
render_frame = global_engine.get("Render/3D/0", {}).get("busy")
|
if driver not in ("i915", "xe"):
|
||||||
video_frame = global_engine.get("Video/0", {}).get("busy")
|
continue
|
||||||
|
|
||||||
if render_frame is not None:
|
pdev = fields.get("drm-pdev", "")
|
||||||
render_global.append(float(render_frame))
|
if target_pdev and pdev != target_pdev:
|
||||||
|
continue
|
||||||
|
|
||||||
if video_frame is not None:
|
client_id = fields.get("drm-client-id")
|
||||||
video_global.append(float(video_frame))
|
if not client_id:
|
||||||
|
continue
|
||||||
|
|
||||||
clients = block.get("clients", {})
|
key = (pdev, client_id, entry)
|
||||||
|
if key in snapshot:
|
||||||
|
continue
|
||||||
|
|
||||||
if clients:
|
engines: dict[str, tuple[int, int]] = {}
|
||||||
for client_block in clients.values():
|
|
||||||
pid = client_block["pid"]
|
|
||||||
|
|
||||||
if pid not in client_usages:
|
if driver == "i915":
|
||||||
client_usages[pid] = []
|
for fkey, engine in _I915_ENGINE_KEYS.items():
|
||||||
|
raw = fields.get(fkey)
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
engines[engine] = (int(raw.split()[0]), 0)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
for suffix, engine in _XE_ENGINE_KEYS.items():
|
||||||
|
busy_raw = fields.get(f"drm-cycles-{suffix}")
|
||||||
|
total_raw = fields.get(f"drm-total-cycles-{suffix}")
|
||||||
|
if not (busy_raw and total_raw):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
engines[engine] = (
|
||||||
|
int(busy_raw.split()[0]),
|
||||||
|
int(total_raw.split()[0]),
|
||||||
|
)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
# Sum all engine-class busy values for this client
|
if not engines:
|
||||||
total_busy = 0.0
|
continue
|
||||||
for engine in client_block.get("engine-classes", {}).values():
|
|
||||||
busy = engine.get("busy")
|
|
||||||
if busy is not None:
|
|
||||||
total_busy += float(busy)
|
|
||||||
|
|
||||||
client_usages[pid].append(total_busy)
|
snapshot[key] = {"driver": driver, "pid": entry, "engines": engines}
|
||||||
|
|
||||||
# Overall GPU usage from rc6 (idle) residency
|
return snapshot
|
||||||
if rc6_values:
|
|
||||||
rc6_avg = sum(rc6_values) / len(rc6_values)
|
|
||||||
results["gpu"] = f"{round(100.0 - rc6_avg, 2)}%"
|
|
||||||
|
|
||||||
results["mem"] = "-%"
|
|
||||||
|
|
||||||
# Compute: Render/3D engine (compute/shader workloads and QSV encode)
|
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, Any]]:
|
||||||
if render_global:
|
"""Get stats by reading DRM fdinfo files.
|
||||||
results["compute"] = f"{round(sum(render_global) / len(render_global), 2)}%"
|
|
||||||
|
|
||||||
# Decoder: Video engine (fixed-function codec)
|
Each DRM client FD exposes monotonic per-engine busy counters via
|
||||||
if video_global:
|
/proc/<pid>/fdinfo/<fd> (i915 since kernel 5.19, Xe since first release).
|
||||||
results["dec"] = f"{round(sum(video_global) / len(video_global), 2)}%"
|
We sample twice and divide busy-time deltas by wall-clock to derive
|
||||||
|
utilization. Render/3D and Compute are pooled into "compute"; Video and
|
||||||
|
VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped
|
||||||
|
to 100%).
|
||||||
|
"""
|
||||||
|
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
|
||||||
|
|
||||||
# Per-client GPU usage (sum of all engines per process)
|
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
|
||||||
if client_usages:
|
if not snapshot_a:
|
||||||
results["clients"] = {}
|
return None
|
||||||
|
|
||||||
for pid, samples in client_usages.items():
|
start = time.monotonic()
|
||||||
if samples:
|
time.sleep(_INTEL_FDINFO_SAMPLE_SECONDS)
|
||||||
results["clients"][pid] = (
|
elapsed_ns = (time.monotonic() - start) * 1e9
|
||||||
f"{round(sum(samples) / len(samples), 2)}%"
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
snapshot_b = _read_intel_drm_fdinfo(target_pdev)
|
||||||
|
if not snapshot_b or elapsed_ns <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
engine_pct: dict[str, float] = {
|
||||||
|
"render": 0.0,
|
||||||
|
"video": 0.0,
|
||||||
|
"video-enhance": 0.0,
|
||||||
|
"compute": 0.0,
|
||||||
|
}
|
||||||
|
pid_pct: dict[str, float] = {}
|
||||||
|
|
||||||
|
for key, data_b in snapshot_b.items():
|
||||||
|
data_a = snapshot_a.get(key)
|
||||||
|
if not data_a or data_a["driver"] != data_b["driver"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
client_total = 0.0
|
||||||
|
for engine, (busy_b, total_b) in data_b["engines"].items():
|
||||||
|
if engine not in engine_pct:
|
||||||
|
continue
|
||||||
|
|
||||||
|
busy_a, total_a = data_a["engines"].get(engine, (busy_b, total_b))
|
||||||
|
|
||||||
|
if data_b["driver"] == "i915":
|
||||||
|
delta = max(0, busy_b - busy_a)
|
||||||
|
pct = min(100.0, delta / elapsed_ns * 100.0)
|
||||||
|
else:
|
||||||
|
delta_busy = max(0, busy_b - busy_a)
|
||||||
|
delta_total = total_b - total_a
|
||||||
|
if delta_total <= 0:
|
||||||
|
continue
|
||||||
|
pct = min(100.0, delta_busy / delta_total * 100.0)
|
||||||
|
|
||||||
|
engine_pct[engine] += pct
|
||||||
|
client_total += pct
|
||||||
|
|
||||||
|
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
|
||||||
|
|
||||||
|
for engine in engine_pct:
|
||||||
|
engine_pct[engine] = min(100.0, engine_pct[engine])
|
||||||
|
|
||||||
|
compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"])
|
||||||
|
dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"])
|
||||||
|
overall_pct = min(100.0, compute_pct + dec_pct)
|
||||||
|
|
||||||
|
results: dict[str, Any] = {
|
||||||
|
"gpu": f"{round(overall_pct, 2)}%",
|
||||||
|
"mem": "-%",
|
||||||
|
"compute": f"{round(compute_pct, 2)}%",
|
||||||
|
"dec": f"{round(dec_pct, 2)}%",
|
||||||
|
}
|
||||||
|
|
||||||
|
if pid_pct:
|
||||||
|
results["clients"] = {
|
||||||
|
pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def get_openvino_npu_stats() -> Optional[dict[str, str]]:
|
def get_openvino_npu_stats() -> Optional[dict[str, str]]:
|
||||||
|
|||||||
576
web/package-lock.json
generated
576
web/package-lock.json
generated
@ -103,7 +103,7 @@
|
|||||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||||
"@vitest/coverage-v8": "^3.0.7",
|
"@vitest/coverage-v8": "^3.0.7",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^10.2.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-jest": "^28.2.0",
|
"eslint-plugin-jest": "^28.2.0",
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
"eslint-plugin-prettier": "^5.0.1",
|
||||||
@ -704,61 +704,136 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.4.0",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
"integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
|
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"eslint-visitor-keys": "^3.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint-community/regexpp": {
|
|
||||||
"version": "4.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
|
|
||||||
"integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint/eslintrc": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^6.12.4",
|
"eslint-visitor-keys": "^3.4.3"
|
||||||
"debug": "^4.3.2",
|
|
||||||
"espree": "^9.6.0",
|
|
||||||
"globals": "^13.19.0",
|
|
||||||
"ignore": "^5.2.0",
|
|
||||||
"import-fresh": "^3.2.1",
|
|
||||||
"js-yaml": "^4.1.0",
|
|
||||||
"minimatch": "^3.1.2",
|
|
||||||
"strip-json-comments": "^3.1.1"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint-community/regexpp": {
|
||||||
"version": "8.57.0",
|
"version": "4.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
||||||
"integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
|
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/config-array": {
|
||||||
|
"version": "0.23.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
|
||||||
|
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint/object-schema": "^3.0.5",
|
||||||
|
"debug": "^4.3.1",
|
||||||
|
"minimatch": "^10.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||||
|
"version": "10.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/config-helpers": {
|
||||||
|
"version": "0.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
|
||||||
|
"integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint/core": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/core": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/json-schema": "^7.0.15"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/object-schema": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/plugin-kit": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint/core": "^1.2.1",
|
||||||
|
"levn": "^0.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
@ -808,19 +883,42 @@
|
|||||||
"react-hook-form": "^7.0.0"
|
"react-hook-form": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@humanwhocodes/config-array": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.11.14",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
"integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
|
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@humanwhocodes/object-schema": "^2.0.2",
|
"@humanfs/types": "^0.15.0"
|
||||||
"debug": "^4.3.1",
|
|
||||||
"minimatch": "^3.0.5"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.10.0"
|
"node": ">=18.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@humanfs/node": {
|
||||||
|
"version": "0.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
|
||||||
|
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@humanfs/core": "^0.19.2",
|
||||||
|
"@humanfs/types": "^0.15.0",
|
||||||
|
"@humanwhocodes/retry": "^0.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@humanfs/types": {
|
||||||
|
"version": "0.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
|
||||||
|
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@humanwhocodes/module-importer": {
|
"node_modules/@humanwhocodes/module-importer": {
|
||||||
@ -836,12 +934,19 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@humanwhocodes/object-schema": {
|
"node_modules/@humanwhocodes/retry": {
|
||||||
"version": "2.0.3",
|
"version": "0.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@inquirer/ansi": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
@ -5693,6 +5798,13 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/esrecurse": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -6194,10 +6306,11 @@
|
|||||||
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA=="
|
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA=="
|
||||||
},
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.11.3",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -6242,9 +6355,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -6639,16 +6752,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/callsites": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/camelcase-css": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@ -7342,19 +7445,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
|
||||||
},
|
},
|
||||||
"node_modules/doctrine": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"esutils": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||||
@ -7523,59 +7613,59 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "8.57.0",
|
"version": "10.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
|
||||||
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
|
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@eslint/eslintrc": "^2.1.4",
|
"@eslint/config-array": "^0.23.5",
|
||||||
"@eslint/js": "8.57.0",
|
"@eslint/config-helpers": "^0.5.5",
|
||||||
"@humanwhocodes/config-array": "^0.11.14",
|
"@eslint/core": "^1.2.1",
|
||||||
|
"@eslint/plugin-kit": "^0.7.1",
|
||||||
|
"@humanfs/node": "^0.16.6",
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
"@humanwhocodes/module-importer": "^1.0.1",
|
||||||
"@nodelib/fs.walk": "^1.2.8",
|
"@humanwhocodes/retry": "^0.4.2",
|
||||||
"@ungap/structured-clone": "^1.2.0",
|
"@types/estree": "^1.0.6",
|
||||||
"ajv": "^6.12.4",
|
"ajv": "^6.14.0",
|
||||||
"chalk": "^4.0.0",
|
"cross-spawn": "^7.0.6",
|
||||||
"cross-spawn": "^7.0.2",
|
|
||||||
"debug": "^4.3.2",
|
"debug": "^4.3.2",
|
||||||
"doctrine": "^3.0.0",
|
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
"eslint-scope": "^7.2.2",
|
"eslint-scope": "^9.1.2",
|
||||||
"eslint-visitor-keys": "^3.4.3",
|
"eslint-visitor-keys": "^5.0.1",
|
||||||
"espree": "^9.6.1",
|
"espree": "^11.2.0",
|
||||||
"esquery": "^1.4.2",
|
"esquery": "^1.7.0",
|
||||||
"esutils": "^2.0.2",
|
"esutils": "^2.0.2",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"file-entry-cache": "^6.0.1",
|
"file-entry-cache": "^8.0.0",
|
||||||
"find-up": "^5.0.0",
|
"find-up": "^5.0.0",
|
||||||
"glob-parent": "^6.0.2",
|
"glob-parent": "^6.0.2",
|
||||||
"globals": "^13.19.0",
|
|
||||||
"graphemer": "^1.4.0",
|
|
||||||
"ignore": "^5.2.0",
|
"ignore": "^5.2.0",
|
||||||
"imurmurhash": "^0.1.4",
|
"imurmurhash": "^0.1.4",
|
||||||
"is-glob": "^4.0.0",
|
"is-glob": "^4.0.0",
|
||||||
"is-path-inside": "^3.0.3",
|
|
||||||
"js-yaml": "^4.1.0",
|
|
||||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||||
"levn": "^0.4.1",
|
"minimatch": "^10.2.4",
|
||||||
"lodash.merge": "^4.6.2",
|
|
||||||
"minimatch": "^3.1.2",
|
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"optionator": "^0.9.3",
|
"optionator": "^0.9.3"
|
||||||
"strip-ansi": "^6.0.1",
|
|
||||||
"text-table": "^0.2.0"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint": "bin/eslint.js"
|
"eslint": "bin/eslint.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://eslint.org/donate"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"jiti": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"jiti": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-prettier": {
|
"node_modules/eslint-config-prettier": {
|
||||||
@ -7699,17 +7789,19 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/eslint-scope": {
|
"node_modules/eslint-scope": {
|
||||||
"version": "7.2.2",
|
"version": "9.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||||
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
|
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/esrecurse": "^4.3.1",
|
||||||
|
"@types/estree": "^1.0.8",
|
||||||
"esrecurse": "^4.3.0",
|
"esrecurse": "^4.3.0",
|
||||||
"estraverse": "^5.2.0"
|
"estraverse": "^5.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
@ -7727,29 +7819,95 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint/node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint/node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint/node_modules/minimatch": {
|
||||||
|
"version": "10.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/espree": {
|
"node_modules/espree": {
|
||||||
"version": "9.6.1",
|
"version": "11.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||||
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
|
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "^8.9.0",
|
"acorn": "^8.16.0",
|
||||||
"acorn-jsx": "^5.3.2",
|
"acorn-jsx": "^5.3.2",
|
||||||
"eslint-visitor-keys": "^3.4.1"
|
"eslint-visitor-keys": "^5.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/espree/node_modules/eslint-visitor-keys": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esquery": {
|
"node_modules/esquery": {
|
||||||
"version": "1.5.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||||
"integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
|
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"estraverse": "^5.1.0"
|
"estraverse": "^5.1.0"
|
||||||
},
|
},
|
||||||
@ -7775,6 +7933,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
@ -8015,16 +8174,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
|
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"flat-cache": "^3.0.4"
|
"flat-cache": "^4.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10.12.0 || >=12.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/file-selector": {
|
"node_modules/file-selector": {
|
||||||
@ -8077,18 +8236,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flat-cache": {
|
"node_modules/flat-cache": {
|
||||||
"version": "3.2.0",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||||
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
|
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"flatted": "^3.2.9",
|
"flatted": "^3.2.9",
|
||||||
"keyv": "^4.5.3",
|
"keyv": "^4.5.4"
|
||||||
"rimraf": "^3.0.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10.12.0 || >=12.0.0"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
@ -8340,26 +8498,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
|
||||||
"version": "7.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
|
||||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"fs.realpath": "^1.0.0",
|
|
||||||
"inflight": "^1.0.4",
|
|
||||||
"inherits": "2",
|
|
||||||
"minimatch": "^3.1.1",
|
|
||||||
"once": "^1.3.0",
|
|
||||||
"path-is-absolute": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@ -8371,22 +8509,6 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globals": {
|
|
||||||
"version": "13.24.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
|
||||||
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"type-fest": "^0.20.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/globby": {
|
"node_modules/globby": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
|
||||||
@ -8939,23 +9061,6 @@
|
|||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/import-fresh": {
|
|
||||||
"version": "3.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
|
||||||
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"parent-module": "^1.0.0",
|
|
||||||
"resolve-from": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/imurmurhash": {
|
"node_modules/imurmurhash": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||||
@ -9176,15 +9281,6 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-path-inside": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-plain-obj": {
|
"node_modules/is-plain-obj": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
|
||||||
@ -9647,12 +9743,6 @@
|
|||||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
|
||||||
"version": "4.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/log-symbols": {
|
"node_modules/log-symbols": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||||
@ -11301,19 +11391,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
"node_modules/parent-module": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"callsites": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parse-entities": {
|
"node_modules/parse-entities": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||||
@ -12512,16 +12589,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/resolve-from": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/restore-cursor": {
|
"node_modules/restore-cursor": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||||
@ -12564,23 +12631,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rimraf": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
|
||||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"glob": "^7.1.3"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"rimraf": "bin.js"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.0",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||||
@ -13092,19 +13142,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-json-comments": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/style-to-js": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.21",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||||
@ -13445,12 +13482,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/text-table": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
@ -13684,19 +13715,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/type-fest": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "(MIT OR CC0-1.0)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|||||||
@ -117,7 +117,7 @@
|
|||||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||||
"@vitest/coverage-v8": "^3.0.7",
|
"@vitest/coverage-v8": "^3.0.7",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^10.2.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-jest": "^28.2.0",
|
"eslint-plugin-jest": "^28.2.0",
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
"eslint-plugin-prettier": "^5.0.1",
|
||||||
|
|||||||
@ -485,6 +485,10 @@
|
|||||||
"hwaccel_args": {
|
"hwaccel_args": {
|
||||||
"label": "Export hwaccel args",
|
"label": "Export hwaccel args",
|
||||||
"description": "Hardware acceleration args to use for export/transcode operations."
|
"description": "Hardware acceleration args to use for export/transcode operations."
|
||||||
|
},
|
||||||
|
"max_concurrent": {
|
||||||
|
"label": "Maximum concurrent exports",
|
||||||
|
"description": "Maximum number of export jobs to process at the same time."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
|
|||||||
@ -242,8 +242,8 @@
|
|||||||
"description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities)."
|
"description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities)."
|
||||||
},
|
},
|
||||||
"intel_gpu_device": {
|
"intel_gpu_device": {
|
||||||
"label": "SR-IOV device",
|
"label": "Intel GPU device",
|
||||||
"description": "Device identifier used when treating Intel GPUs as SR-IOV to fix GPU stats."
|
"description": "PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version_check": {
|
"version_check": {
|
||||||
@ -1000,6 +1000,10 @@
|
|||||||
"hwaccel_args": {
|
"hwaccel_args": {
|
||||||
"label": "Export hwaccel args",
|
"label": "Export hwaccel args",
|
||||||
"description": "Hardware acceleration args to use for export/transcode operations."
|
"description": "Hardware acceleration args to use for export/transcode operations."
|
||||||
|
},
|
||||||
|
"max_concurrent": {
|
||||||
|
"label": "Maximum concurrent exports",
|
||||||
|
"description": "Maximum number of export jobs to process at the same time."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
|
|||||||
@ -20,7 +20,18 @@
|
|||||||
"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",
|
||||||
"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",
|
||||||
|
"overriddenInCameras": {
|
||||||
|
"label_one": "Overridden in {{count}} camera",
|
||||||
|
"label_other": "Overridden in {{count}} cameras",
|
||||||
|
"tooltip_one": "{{count}} camera overrides values in this section. Click to see details.",
|
||||||
|
"tooltip_other": "{{count}} cameras override values in this section. Click to see details.",
|
||||||
|
"heading_one": "This global section has fields that are overridden in {{count}} camera.",
|
||||||
|
"heading_other": "This global section has fields that are overridden in {{count}} cameras.",
|
||||||
|
"othersField_one": "{{count}} other",
|
||||||
|
"othersField_other": "{{count}} others",
|
||||||
|
"profilePrefix": "{{profile}} profile: {{fields}}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import {
|
|||||||
} from "./section-special-cases";
|
} from "./section-special-cases";
|
||||||
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 { 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";
|
||||||
@ -1263,6 +1264,9 @@ export function ConfigSection({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||||
|
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||||
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{t("button.modified", {
|
{t("button.modified", {
|
||||||
@ -1334,6 +1338,9 @@ export function ConfigSection({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||||
|
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||||
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
303
web/src/components/config-form/sections/CameraOverridesBadge.tsx
Normal file
303
web/src/components/config-form/sections/CameraOverridesBadge.tsx
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import useSWR from "swr";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { LuChevronDown } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
CameraOverrideEntry,
|
||||||
|
FieldDelta,
|
||||||
|
useCamerasOverridingSection,
|
||||||
|
} from "@/hooks/use-config-override";
|
||||||
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import type { ProfilesApiResponse } from "@/types/profile";
|
||||||
|
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||||
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
|
import { formatList } from "@/utils/stringUtil";
|
||||||
|
import { getSectionConfig } from "@/utils/configUtil";
|
||||||
|
|
||||||
|
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||||
|
detect: "cameraDetect",
|
||||||
|
ffmpeg: "cameraFfmpeg",
|
||||||
|
record: "cameraRecording",
|
||||||
|
snapshots: "cameraSnapshots",
|
||||||
|
motion: "cameraMotion",
|
||||||
|
objects: "cameraObjects",
|
||||||
|
review: "cameraReview",
|
||||||
|
audio: "cameraAudioEvents",
|
||||||
|
audio_transcription: "cameraAudioTranscription",
|
||||||
|
notifications: "cameraNotifications",
|
||||||
|
live: "cameraLivePlayback",
|
||||||
|
birdseye: "cameraBirdseye",
|
||||||
|
face_recognition: "cameraFaceRecognition",
|
||||||
|
lpr: "cameraLpr",
|
||||||
|
timestamp_style: "cameraTimestampStyle",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_FIELDS_PER_CAMERA = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrichment sections where the cross-camera override badge should be
|
||||||
|
* suppressed because they're effectively global-only (or per-camera
|
||||||
|
* configuration there isn't a useful affordance to surface here).
|
||||||
|
* Face recognition and LPR are intentionally omitted so the badge does show
|
||||||
|
* on those enrichment pages.
|
||||||
|
*/
|
||||||
|
const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
||||||
|
"semantic_search",
|
||||||
|
"genai",
|
||||||
|
"classification",
|
||||||
|
"audio_transcription",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a delta path against a hidden-field pattern. Supports literal prefixes
|
||||||
|
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
|
||||||
|
* matching exactly one path segment (e.g. "filters.*.mask").
|
||||||
|
*/
|
||||||
|
function pathMatchesHiddenPattern(path: string, pattern: string): boolean {
|
||||||
|
if (!pattern) return false;
|
||||||
|
if (!pattern.includes("*")) {
|
||||||
|
return path === pattern || path.startsWith(`${pattern}.`);
|
||||||
|
}
|
||||||
|
const patternSegments = pattern.split(".");
|
||||||
|
const pathSegments = path.split(".");
|
||||||
|
if (pathSegments.length < patternSegments.length) return false;
|
||||||
|
for (let i = 0; i < patternSegments.length; i += 1) {
|
||||||
|
if (patternSegments[i] === "*") continue;
|
||||||
|
if (patternSegments[i] !== pathSegments[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CameraEntryProps = {
|
||||||
|
sectionPath: string;
|
||||||
|
entry: CameraOverrideEntry;
|
||||||
|
cameraPage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SourceGroup = {
|
||||||
|
/** undefined → camera-level; string → profile name */
|
||||||
|
profileName: string | undefined;
|
||||||
|
deltas: FieldDelta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] {
|
||||||
|
const cameraDeltas: FieldDelta[] = [];
|
||||||
|
const byProfile = new Map<string, FieldDelta[]>();
|
||||||
|
for (const delta of deltas) {
|
||||||
|
if (delta.profileName) {
|
||||||
|
const arr = byProfile.get(delta.profileName) ?? [];
|
||||||
|
arr.push(delta);
|
||||||
|
byProfile.set(delta.profileName, arr);
|
||||||
|
} else {
|
||||||
|
cameraDeltas.push(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groups: SourceGroup[] = [];
|
||||||
|
if (cameraDeltas.length > 0) {
|
||||||
|
groups.push({ profileName: undefined, deltas: cameraDeltas });
|
||||||
|
}
|
||||||
|
for (const [profileName, group] of byProfile) {
|
||||||
|
groups.push({ profileName, deltas: group });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||||
|
const { t, i18n } = useTranslation([
|
||||||
|
"config/global",
|
||||||
|
"views/settings",
|
||||||
|
"objects",
|
||||||
|
]);
|
||||||
|
const friendlyName = useCameraFriendlyName(entry.camera);
|
||||||
|
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||||
|
|
||||||
|
const profileFriendlyNames = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
profilesData?.profiles?.forEach((p) => map.set(p.name, p.friendly_name));
|
||||||
|
return map;
|
||||||
|
}, [profilesData]);
|
||||||
|
|
||||||
|
const fieldLabel = (fieldPath: string) => {
|
||||||
|
if (!fieldPath) {
|
||||||
|
const sectionKey = `${sectionPath}.label`;
|
||||||
|
return i18n.exists(sectionKey, { ns: "config/global" })
|
||||||
|
? t(sectionKey, { ns: "config/global" })
|
||||||
|
: humanizeKey(sectionPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = fieldPath.split(".");
|
||||||
|
|
||||||
|
// Most specific: try the full nested path
|
||||||
|
const fullKey = `${sectionPath}.${fieldPath}.label`;
|
||||||
|
if (i18n.exists(fullKey, { ns: "config/global" })) {
|
||||||
|
return t(fullKey, { ns: "config/global" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try dropping each intermediate segment in turn — those are typically
|
||||||
|
// user-defined dict keys (object class names, zone names, etc.) that
|
||||||
|
// don't have their own label entries. Prepend the dropped segment as
|
||||||
|
// context to disambiguate (e.g. "Person · Minimum object area").
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
|
||||||
|
".",
|
||||||
|
);
|
||||||
|
if (!reduced) continue;
|
||||||
|
const reducedKey = `${sectionPath}.${reduced}.label`;
|
||||||
|
if (i18n.exists(reducedKey, { ns: "config/global" })) {
|
||||||
|
const resolvedLabel = t(reducedKey, { ns: "config/global" });
|
||||||
|
const dropped = segments[i];
|
||||||
|
// Object class names ("person", "car", "fox") have translations in
|
||||||
|
// the `objects` namespace; fall back to humanizing the raw key for
|
||||||
|
// anything that isn't a known label.
|
||||||
|
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
|
||||||
|
? t(dropped, { ns: "objects" })
|
||||||
|
: humanizeKey(dropped);
|
||||||
|
return `${droppedLabel} · ${resolvedLabel}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: humanize the leaf segment
|
||||||
|
return humanizeKey(segments[segments.length - 1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDeltas = (deltas: FieldDelta[]) => {
|
||||||
|
const visibleLabels = deltas
|
||||||
|
.slice(0, MAX_FIELDS_PER_CAMERA)
|
||||||
|
.map((delta) => fieldLabel(delta.fieldPath));
|
||||||
|
const hiddenCount = deltas.length - visibleLabels.length;
|
||||||
|
const labelsForList =
|
||||||
|
hiddenCount > 0
|
||||||
|
? [
|
||||||
|
...visibleLabels,
|
||||||
|
t("button.overriddenInCameras.othersField", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: hiddenCount,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: visibleLabels;
|
||||||
|
return formatList(labelsForList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups = groupDeltasBySource(entry.fieldDeltas);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 text-xs">
|
||||||
|
{cameraPage ? (
|
||||||
|
<Link
|
||||||
|
to={`/settings?page=${cameraPage}&camera=${encodeURIComponent(entry.camera)}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{friendlyName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{friendlyName}</span>
|
||||||
|
)}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<span
|
||||||
|
key={group.profileName ?? "__camera__"}
|
||||||
|
className="ml-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{group.profileName
|
||||||
|
? t("button.overriddenInCameras.profilePrefix", {
|
||||||
|
ns: "views/settings",
|
||||||
|
profile:
|
||||||
|
profileFriendlyNames.get(group.profileName) ??
|
||||||
|
group.profileName,
|
||||||
|
fields: formatDeltas(group.deltas),
|
||||||
|
})
|
||||||
|
: formatDeltas(group.deltas)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sectionPath: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CameraOverridesBadge({ sectionPath, className }: Props) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const rawEntries = useCamerasOverridingSection(config, sectionPath);
|
||||||
|
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
const hiddenFields =
|
||||||
|
getSectionConfig(sectionPath, "global").hiddenFields ?? [];
|
||||||
|
if (hiddenFields.length === 0) return rawEntries;
|
||||||
|
return rawEntries
|
||||||
|
.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
fieldDeltas: entry.fieldDeltas.filter(
|
||||||
|
(delta) =>
|
||||||
|
!hiddenFields.some((pattern) =>
|
||||||
|
pathMatchesHiddenPattern(delta.fieldPath, pattern),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.fieldDeltas.length > 0);
|
||||||
|
}, [rawEntries, sectionPath]);
|
||||||
|
|
||||||
|
if (SECTIONS_WITHOUT_OVERRIDE_BADGE.has(sectionPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraPage = CAMERA_PAGE_BY_SECTION[sectionPath];
|
||||||
|
const count = entries.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={`cursor-pointer border-2 border-selected text-xs text-primary-variant ${className ?? ""}`}
|
||||||
|
aria-label={t("button.overriddenInCameras.tooltip", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: count,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{t("button.overriddenInCameras.label", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: count,
|
||||||
|
})}
|
||||||
|
</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">
|
||||||
|
{t("button.overriddenInCameras.heading", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: count,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="scrollbar-container flex max-h-[40dvh] flex-col gap-2 overflow-y-auto pr-4">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<CameraEntry
|
||||||
|
key={entry.camera}
|
||||||
|
sectionPath={sectionPath}
|
||||||
|
entry={entry}
|
||||||
|
cameraPage={cameraPage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -202,6 +202,49 @@ export function useConfigOverride({
|
|||||||
}, [config, cameraName, sectionPath, compareFields]);
|
}, [config, cameraName, sectionPath, compareFields]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections that can be overridden per-camera, with optional compareFields
|
||||||
|
* filters that scope the override comparison to a subset of fields.
|
||||||
|
*/
|
||||||
|
export const OVERRIDABLE_SECTIONS: ReadonlyArray<{
|
||||||
|
key: string;
|
||||||
|
compareFields?: string[];
|
||||||
|
}> = [
|
||||||
|
{ key: "detect" },
|
||||||
|
{ key: "record" },
|
||||||
|
{ key: "snapshots" },
|
||||||
|
{ key: "motion" },
|
||||||
|
{ key: "objects" },
|
||||||
|
{ key: "review" },
|
||||||
|
{ key: "audio" },
|
||||||
|
{ key: "notifications" },
|
||||||
|
{ key: "live" },
|
||||||
|
{ key: "timestamp_style" },
|
||||||
|
{
|
||||||
|
key: "audio_transcription",
|
||||||
|
compareFields: ["enabled", "live_enabled"],
|
||||||
|
},
|
||||||
|
{ key: "birdseye", compareFields: ["enabled", "mode"] },
|
||||||
|
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
|
||||||
|
{
|
||||||
|
key: "ffmpeg",
|
||||||
|
compareFields: [
|
||||||
|
"path",
|
||||||
|
"global_args",
|
||||||
|
"hwaccel_args",
|
||||||
|
"input_args",
|
||||||
|
"output_args",
|
||||||
|
"retry_interval",
|
||||||
|
"apple_compatibility",
|
||||||
|
"gpu",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lpr",
|
||||||
|
compareFields: ["enabled", "min_area", "enhancement"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get all overridden fields for a camera
|
* Hook to get all overridden fields for a camera
|
||||||
*/
|
*/
|
||||||
@ -221,47 +264,7 @@ export function useAllCameraOverrides(
|
|||||||
|
|
||||||
const overriddenSections: string[] = [];
|
const overriddenSections: string[] = [];
|
||||||
|
|
||||||
// Check each section that can be overridden
|
for (const { key, compareFields } of OVERRIDABLE_SECTIONS) {
|
||||||
const sectionsToCheck: Array<{
|
|
||||||
key: string;
|
|
||||||
compareFields?: string[];
|
|
||||||
}> = [
|
|
||||||
{ key: "detect" },
|
|
||||||
{ key: "record" },
|
|
||||||
{ key: "snapshots" },
|
|
||||||
{ key: "motion" },
|
|
||||||
{ key: "objects" },
|
|
||||||
{ key: "review" },
|
|
||||||
{ key: "audio" },
|
|
||||||
{ key: "notifications" },
|
|
||||||
{ key: "live" },
|
|
||||||
{ key: "timestamp_style" },
|
|
||||||
{
|
|
||||||
key: "audio_transcription",
|
|
||||||
compareFields: ["enabled", "live_enabled"],
|
|
||||||
},
|
|
||||||
{ key: "birdseye", compareFields: ["enabled", "mode"] },
|
|
||||||
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
|
|
||||||
{
|
|
||||||
key: "ffmpeg",
|
|
||||||
compareFields: [
|
|
||||||
"path",
|
|
||||||
"global_args",
|
|
||||||
"hwaccel_args",
|
|
||||||
"input_args",
|
|
||||||
"output_args",
|
|
||||||
"retry_interval",
|
|
||||||
"apple_compatibility",
|
|
||||||
"gpu",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "lpr",
|
|
||||||
compareFields: ["enabled", "min_area", "enhancement"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { key, compareFields } of sectionsToCheck) {
|
|
||||||
const globalValue = normalizeConfigValue(get(config, key));
|
const globalValue = normalizeConfigValue(get(config, key));
|
||||||
const cameraValue = normalizeConfigValue(
|
const cameraValue = normalizeConfigValue(
|
||||||
getBaseCameraSectionValue(config, cameraName, key),
|
getBaseCameraSectionValue(config, cameraName, key),
|
||||||
@ -286,3 +289,252 @@ export function useAllCameraOverrides(
|
|||||||
return overriddenSections;
|
return overriddenSections;
|
||||||
}, [config, cameraName]);
|
}, [config, cameraName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FieldDelta {
|
||||||
|
/** Path relative to the section (e.g. "genai.enabled") */
|
||||||
|
fieldPath: string;
|
||||||
|
globalValue: unknown;
|
||||||
|
cameraValue: unknown;
|
||||||
|
/** Profile name when the override originates from a profile; undefined for camera-level overrides */
|
||||||
|
profileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraOverrideEntry {
|
||||||
|
camera: string;
|
||||||
|
fieldDeltas: FieldDelta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect leaf-level field differences between a global section value
|
||||||
|
* and a camera section value. When compareFields is provided, only those
|
||||||
|
* paths are compared; otherwise the objects are walked recursively.
|
||||||
|
*/
|
||||||
|
function collectFieldDeltas(
|
||||||
|
globalValue: JsonValue,
|
||||||
|
cameraValue: JsonValue,
|
||||||
|
compareFields?: string[],
|
||||||
|
pathPrefix = "",
|
||||||
|
): FieldDelta[] {
|
||||||
|
if (compareFields) {
|
||||||
|
if (compareFields.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const deltas: FieldDelta[] = [];
|
||||||
|
for (const path of compareFields) {
|
||||||
|
const g = get(globalValue, path);
|
||||||
|
const c = get(cameraValue, path);
|
||||||
|
if (!isEqual(g, c)) {
|
||||||
|
deltas.push({ fieldPath: path, globalValue: g, cameraValue: c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJsonObject(globalValue) && isJsonObject(cameraValue)) {
|
||||||
|
const deltas: FieldDelta[] = [];
|
||||||
|
const keys = new Set([
|
||||||
|
...Object.keys(globalValue),
|
||||||
|
...Object.keys(cameraValue),
|
||||||
|
]);
|
||||||
|
for (const key of keys) {
|
||||||
|
const g = (globalValue as JsonObject)[key];
|
||||||
|
const c = (cameraValue as JsonObject)[key];
|
||||||
|
if (isEqual(g, c)) continue;
|
||||||
|
const childPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||||
|
if (isJsonObject(g) && isJsonObject(c)) {
|
||||||
|
deltas.push(...collectFieldDeltas(g, c, undefined, childPath));
|
||||||
|
} else {
|
||||||
|
deltas.push({ fieldPath: childPath, globalValue: g, cameraValue: c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEqual(globalValue, cameraValue)) {
|
||||||
|
return [{ fieldPath: pathPrefix, globalValue, cameraValue }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a partial config object and return the dot-paths of every leaf value
|
||||||
|
* (primitive or array) actually defined on it. Used to limit profile-vs-global
|
||||||
|
* diffs to keys the profile actually sets, avoiding false "undefined" deltas
|
||||||
|
* for fields the profile leaves unspecified.
|
||||||
|
*/
|
||||||
|
function collectDefinedLeafPaths(value: JsonValue, prefix = ""): string[] {
|
||||||
|
if (!isJsonObject(value)) {
|
||||||
|
return prefix ? [prefix] : [];
|
||||||
|
}
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (const [key, val] of Object.entries(value as JsonObject)) {
|
||||||
|
const childPath = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (isJsonObject(val)) {
|
||||||
|
paths.push(...collectDefinedLeafPaths(val as JsonValue, childPath));
|
||||||
|
} else {
|
||||||
|
paths.push(childPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathAllowed(path: string, compareFields?: string[]): boolean {
|
||||||
|
if (!compareFields) return true;
|
||||||
|
return compareFields.some(
|
||||||
|
(allowed) => path === allowed || path.startsWith(`${allowed}.`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some Frigate sections (notably `motion`) are dumped by the backend with
|
||||||
|
* `exclude_unset=True`, so when the user hasn't explicitly written the section
|
||||||
|
* in their global YAML the API returns null even though every camera still
|
||||||
|
* gets defaults applied at runtime. To still detect cross-camera differences
|
||||||
|
* in those sections we synthesize a baseline by taking the modal (most common)
|
||||||
|
* value at each leaf path across cameras — cameras whose value diverges from
|
||||||
|
* the modal are treated as overriding.
|
||||||
|
*/
|
||||||
|
function deriveSyntheticGlobalValue(
|
||||||
|
cameraSectionValues: JsonValue[],
|
||||||
|
compareFields?: string[],
|
||||||
|
): JsonObject {
|
||||||
|
const cameras = cameraSectionValues.filter(isJsonObject) as JsonObject[];
|
||||||
|
if (cameras.length === 0) return {};
|
||||||
|
|
||||||
|
const allPaths = new Set<string>();
|
||||||
|
for (const cam of cameras) {
|
||||||
|
for (const path of collectDefinedLeafPaths(cam as JsonValue)) {
|
||||||
|
if (!isPathAllowed(path, compareFields)) continue;
|
||||||
|
allPaths.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline: JsonObject = {};
|
||||||
|
for (const path of allPaths) {
|
||||||
|
const counts = new Map<string, { value: unknown; count: number }>();
|
||||||
|
for (const cam of cameras) {
|
||||||
|
const v = get(cam, path);
|
||||||
|
const key = JSON.stringify(v ?? null);
|
||||||
|
const existing = counts.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
} else {
|
||||||
|
counts.set(key, { value: v, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let modal: { value: unknown; count: number } | undefined;
|
||||||
|
for (const entry of counts.values()) {
|
||||||
|
if (!modal || entry.count > modal.count) modal = entry;
|
||||||
|
}
|
||||||
|
if (modal) {
|
||||||
|
set(baseline, path, modal.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths that are intentionally hidden from the cross-camera override summary
|
||||||
|
* because they're inherently per-camera (mask polygons, zone definitions) and
|
||||||
|
* would otherwise dominate the popover with noise. Excludes any path where
|
||||||
|
* `mask` appears as a path segment, so nested keys under a mask dict (e.g.
|
||||||
|
* `mask.global_object_mask_1.coordinates`) are also filtered.
|
||||||
|
*/
|
||||||
|
function isCrossCameraIgnoredPath(path: string): boolean {
|
||||||
|
if (!path) return false;
|
||||||
|
return path.split(".").includes("mask");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to find every camera that overrides a given global section. Returns
|
||||||
|
* one entry per overriding camera with the specific field-level deltas.
|
||||||
|
* Considers both the camera's own (pre-profile) section value and any of its
|
||||||
|
* defined profiles, so a field overridden only inside a profile still surfaces.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const entries = useCamerasOverridingSection(config, "review");
|
||||||
|
* // [{ camera: "front_door", fieldDeltas: [{ fieldPath: "genai.enabled", ... }] }]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useCamerasOverridingSection(
|
||||||
|
config: FrigateConfig | undefined,
|
||||||
|
sectionPath: string,
|
||||||
|
): CameraOverrideEntry[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!config?.cameras || !sectionPath) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
|
||||||
|
const compareFields = sectionMeta?.compareFields;
|
||||||
|
|
||||||
|
const cameraNames = Object.keys(config.cameras);
|
||||||
|
const cameraSectionValues = cameraNames.map((name) =>
|
||||||
|
normalizeConfigValue(
|
||||||
|
getBaseCameraSectionValue(config, name, sectionPath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawGlobalValue = get(config, sectionPath);
|
||||||
|
const globalValue: JsonValue =
|
||||||
|
rawGlobalValue == null
|
||||||
|
? deriveSyntheticGlobalValue(cameraSectionValues, compareFields)
|
||||||
|
: normalizeConfigValue(rawGlobalValue);
|
||||||
|
|
||||||
|
const entries: CameraOverrideEntry[] = [];
|
||||||
|
for (let idx = 0; idx < cameraNames.length; idx += 1) {
|
||||||
|
const cameraName = cameraNames[idx];
|
||||||
|
const cameraConfig = config.cameras[cameraName];
|
||||||
|
const deltasByPath = new Map<string, FieldDelta>();
|
||||||
|
|
||||||
|
// 1. Camera-level overrides (uses base_config when a profile is active)
|
||||||
|
const cameraValue = cameraSectionValues[idx];
|
||||||
|
for (const delta of collectFieldDeltas(
|
||||||
|
globalValue,
|
||||||
|
cameraValue,
|
||||||
|
compareFields,
|
||||||
|
)) {
|
||||||
|
if (isCrossCameraIgnoredPath(delta.fieldPath)) continue;
|
||||||
|
deltasByPath.set(delta.fieldPath, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Profile-level overrides — diff only the paths each profile actually
|
||||||
|
// defines, so unspecified-in-profile fields don't register as deltas.
|
||||||
|
const profiles = cameraConfig?.profiles ?? {};
|
||||||
|
for (const profileName of Object.keys(profiles)) {
|
||||||
|
const profileSection = (
|
||||||
|
profiles[profileName] as Record<string, unknown> | undefined
|
||||||
|
)?.[sectionPath];
|
||||||
|
if (profileSection === undefined) continue;
|
||||||
|
const normalizedProfile = normalizeConfigValue(
|
||||||
|
profileSection as JsonValue,
|
||||||
|
);
|
||||||
|
for (const path of collectDefinedLeafPaths(normalizedProfile)) {
|
||||||
|
if (deltasByPath.has(path)) continue;
|
||||||
|
if (isCrossCameraIgnoredPath(path)) continue;
|
||||||
|
if (!isPathAllowed(path, compareFields)) continue;
|
||||||
|
const g = get(globalValue, path);
|
||||||
|
const p = get(normalizedProfile, path);
|
||||||
|
if (!isEqual(g, p)) {
|
||||||
|
deltasByPath.set(path, {
|
||||||
|
fieldPath: path,
|
||||||
|
globalValue: g,
|
||||||
|
cameraValue: p,
|
||||||
|
profileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltasByPath.size > 0) {
|
||||||
|
entries.push({
|
||||||
|
camera: cameraName,
|
||||||
|
fieldDeltas: Array.from(deltasByPath.values()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}, [config, sectionPath]);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
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 type { PolygonType } from "@/types/canvas";
|
import type { PolygonType } from "@/types/canvas";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@ -167,6 +168,9 @@ export function SingleSectionPage({
|
|||||||
</div>
|
</div>
|
||||||
{/* Desktop: badge inline next to title */}
|
{/* Desktop: badge inline next to title */}
|
||||||
<div className="hidden shrink-0 sm:flex sm:flex-wrap sm:items-center sm:gap-2">
|
<div className="hidden shrink-0 sm:flex sm:flex-wrap sm:items-center sm:gap-2">
|
||||||
|
{level === "global" && showOverrideIndicator && (
|
||||||
|
<CameraOverridesBadge sectionPath={sectionKey} />
|
||||||
|
)}
|
||||||
{level === "camera" &&
|
{level === "camera" &&
|
||||||
showOverrideIndicator &&
|
showOverrideIndicator &&
|
||||||
sectionStatus.isOverridden && (
|
sectionStatus.isOverridden && (
|
||||||
@ -224,6 +228,9 @@ export function SingleSectionPage({
|
|||||||
</div>
|
</div>
|
||||||
{/* Mobile: badge below title/description */}
|
{/* Mobile: badge below title/description */}
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:hidden">
|
<div className="flex flex-wrap items-center gap-2 sm:hidden">
|
||||||
|
{level === "global" && showOverrideIndicator && (
|
||||||
|
<CameraOverridesBadge sectionPath={sectionKey} />
|
||||||
|
)}
|
||||||
{level === "camera" &&
|
{level === "camera" &&
|
||||||
showOverrideIndicator &&
|
showOverrideIndicator &&
|
||||||
sectionStatus.isOverridden && (
|
sectionStatus.isOverridden && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user