mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-27 18:48:22 +03:00
Merge pull request #4 from ibs0d/codex/extend-system-metrics-for-multi-root-support-r89rnx
Expose recording roots and per-root camera usage in `/recordings/storage` and UI
This commit is contained in:
commit
22c957fca4
@ -37,15 +37,16 @@ router = APIRouter(tags=[Tags.recordings])
|
|||||||
def get_recordings_storage_usage(request: Request):
|
def get_recordings_storage_usage(request: Request):
|
||||||
storage_stats = request.app.stats_emitter.get_latest_stats()["service"]["storage"]
|
storage_stats = request.app.stats_emitter.get_latest_stats()["service"]["storage"]
|
||||||
|
|
||||||
recording_paths = request.app.frigate_config.get_recordings_paths()
|
configured_recording_paths = request.app.frigate_config.get_recordings_paths()
|
||||||
recording_stats = [storage_stats.get(path, {}) for path in recording_paths]
|
recording_stats = [storage_stats.get(path, {}) for path in configured_recording_paths]
|
||||||
total_mb = sum(stat.get("total", 0) for stat in recording_stats)
|
total_mb = sum(stat.get("total", 0) for stat in recording_stats)
|
||||||
|
|
||||||
if total_mb == 0:
|
if total_mb == 0:
|
||||||
return JSONResponse({})
|
return JSONResponse({})
|
||||||
|
|
||||||
camera_usages: dict[str, dict] = (
|
camera_usages: dict[str, dict] = request.app.storage_maintainer.calculate_camera_usages()
|
||||||
request.app.storage_maintainer.calculate_camera_usages()
|
root_camera_usages: dict[str, dict] = (
|
||||||
|
request.app.storage_maintainer.calculate_camera_usages_by_root()
|
||||||
)
|
)
|
||||||
|
|
||||||
for camera_name in camera_usages.keys():
|
for camera_name in camera_usages.keys():
|
||||||
@ -54,7 +55,66 @@ def get_recordings_storage_usage(request: Request):
|
|||||||
camera_usages.get(camera_name, {}).get("usage", 0) / total_mb
|
camera_usages.get(camera_name, {}).get("usage", 0) / total_mb
|
||||||
) * 100
|
) * 100
|
||||||
|
|
||||||
return JSONResponse(content=camera_usages)
|
recording_roots = []
|
||||||
|
all_recording_roots = sorted(
|
||||||
|
set(configured_recording_paths).union(root_camera_usages.keys())
|
||||||
|
)
|
||||||
|
|
||||||
|
for root_path in all_recording_roots:
|
||||||
|
root_stats = storage_stats.get(root_path, {})
|
||||||
|
total = root_stats.get("total", 0)
|
||||||
|
used = root_stats.get("used", 0)
|
||||||
|
free = root_stats.get("free", 0)
|
||||||
|
root_usage_percent = (used / total) * 100 if total else 0
|
||||||
|
|
||||||
|
root_usage = root_camera_usages.get(
|
||||||
|
root_path,
|
||||||
|
{
|
||||||
|
"path": root_path,
|
||||||
|
"is_default": False,
|
||||||
|
"recordings_size": 0,
|
||||||
|
"cameras": [],
|
||||||
|
"configured_cameras": [],
|
||||||
|
"camera_usages": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
camera_usages_in_root = root_usage.get("camera_usages", {})
|
||||||
|
for camera in camera_usages_in_root.values():
|
||||||
|
camera["usage_percent"] = (
|
||||||
|
(camera.get("usage", 0) / root_usage["recordings_size"]) * 100
|
||||||
|
if root_usage["recordings_size"]
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
recording_roots.append(
|
||||||
|
{
|
||||||
|
"path": root_path,
|
||||||
|
"total": total,
|
||||||
|
"used": used,
|
||||||
|
"free": free,
|
||||||
|
"usage_percent": root_usage_percent,
|
||||||
|
"recordings_size": root_usage["recordings_size"],
|
||||||
|
"is_default": root_usage["is_default"],
|
||||||
|
"cameras": root_usage["cameras"],
|
||||||
|
"configured_cameras": root_usage["configured_cameras"],
|
||||||
|
"camera_usages": camera_usages_in_root,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# New response shape for structured consumers.
|
||||||
|
response = {
|
||||||
|
"cameras": camera_usages,
|
||||||
|
"recording_roots": recording_roots,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backward compatibility for legacy consumers expecting top-level camera keys.
|
||||||
|
response.update(camera_usages)
|
||||||
|
|
||||||
|
# Backward compatibility for PR#3 clients that already consume this transitional key.
|
||||||
|
response["__recording_roots"] = recording_roots
|
||||||
|
|
||||||
|
return JSONResponse(content=response)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
from peewee import SQL, fn
|
from peewee import SQL, fn
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
from frigate.const import RECORD_DIR, REPLAY_CAMERA_PREFIX
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings
|
||||||
from frigate.util.builtin import clear_and_unlink
|
from frigate.util.builtin import clear_and_unlink
|
||||||
|
|
||||||
@ -103,6 +103,88 @@ class StorageMaintainer(threading.Thread):
|
|||||||
|
|
||||||
return usages
|
return usages
|
||||||
|
|
||||||
|
def calculate_camera_usages_by_root(self) -> dict[str, dict]:
|
||||||
|
"""Calculate camera storage usage grouped by recordings root."""
|
||||||
|
root_usages: dict[str, dict] = {}
|
||||||
|
|
||||||
|
root_camera_stats = (
|
||||||
|
Recordings.select(
|
||||||
|
Recordings.camera,
|
||||||
|
Recordings.path,
|
||||||
|
fn.SUM(Recordings.segment_size).alias("usage"),
|
||||||
|
)
|
||||||
|
.where(Recordings.segment_size != 0)
|
||||||
|
.group_by(Recordings.camera, Recordings.path)
|
||||||
|
.namedtuples()
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
|
||||||
|
for stat in root_camera_stats:
|
||||||
|
camera = stat.camera
|
||||||
|
|
||||||
|
# Skip replay cameras
|
||||||
|
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||||
|
continue
|
||||||
|
|
||||||
|
root_path = self._get_recordings_root_from_path(stat.path, camera)
|
||||||
|
if not root_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
camera_usage = stat.usage or 0
|
||||||
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
camera_friendly_name = (
|
||||||
|
getattr(camera_config, "friendly_name", None) if camera_config else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if root_path not in root_usages:
|
||||||
|
root_usages[root_path] = {
|
||||||
|
"path": root_path,
|
||||||
|
"is_default": root_path == RECORD_DIR,
|
||||||
|
"recordings_size": 0,
|
||||||
|
"cameras": [],
|
||||||
|
"configured_cameras": [],
|
||||||
|
"camera_usages": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
root_usages[root_path]["recordings_size"] += camera_usage
|
||||||
|
|
||||||
|
if camera not in root_usages[root_path]["camera_usages"]:
|
||||||
|
root_usages[root_path]["cameras"].append(camera)
|
||||||
|
root_usages[root_path]["camera_usages"][camera] = {
|
||||||
|
"usage": 0,
|
||||||
|
"bandwidth": self.camera_storage_stats.get(camera, {}).get(
|
||||||
|
"bandwidth", 0
|
||||||
|
),
|
||||||
|
"friendly_name": camera_friendly_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
root_usages[root_path]["camera_usages"][camera]["usage"] += camera_usage
|
||||||
|
|
||||||
|
for root_path, root in root_usages.items():
|
||||||
|
root["cameras"] = sorted(root["cameras"])
|
||||||
|
root["configured_cameras"] = sorted(
|
||||||
|
[
|
||||||
|
camera
|
||||||
|
for camera in self.config.cameras.keys()
|
||||||
|
if self.config.get_camera_recordings_path(camera) == root_path
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return root_usages
|
||||||
|
|
||||||
|
def _get_recordings_root_from_path(self, recording_path: str, camera: str) -> str:
|
||||||
|
camera_segment = f"/{camera}/"
|
||||||
|
|
||||||
|
if camera_segment in recording_path:
|
||||||
|
return recording_path.split(camera_segment, 1)[0].rstrip("/") or "/"
|
||||||
|
|
||||||
|
# Fallback for unexpected path layouts; expected format is root/camera/date/file
|
||||||
|
path = Path(recording_path)
|
||||||
|
if len(path.parents) >= 3:
|
||||||
|
return str(path.parents[2]).rstrip("/") or "/"
|
||||||
|
|
||||||
|
return str(path.parent).rstrip("/") or "/"
|
||||||
|
|
||||||
def _get_path_bandwidths(self) -> dict[str, float]:
|
def _get_path_bandwidths(self) -> dict[str, float]:
|
||||||
bandwidth_per_path: dict[str, float] = {}
|
bandwidth_per_path: dict[str, float] = {}
|
||||||
|
|
||||||
|
|||||||
229
frigate/test/http_api/test_http_recordings_storage.py
Normal file
229
frigate/test/http_api/test_http_recordings_storage.py
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from frigate.models import Recordings
|
||||||
|
from frigate.stats.emitter import StatsEmitter
|
||||||
|
from frigate.storage import StorageMaintainer
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpRecordingsStorage(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([Recordings])
|
||||||
|
|
||||||
|
def _build_app(self):
|
||||||
|
stats = Mock(spec=StatsEmitter)
|
||||||
|
stats.get_latest_stats.return_value = self.test_stats
|
||||||
|
|
||||||
|
app = super().create_app(stats)
|
||||||
|
app.storage_maintainer = StorageMaintainer(app.frigate_config, Mock())
|
||||||
|
return app
|
||||||
|
|
||||||
|
def test_recordings_storage_default_only(self):
|
||||||
|
app = self._build_app()
|
||||||
|
|
||||||
|
Recordings.insert(
|
||||||
|
id="front_default_1",
|
||||||
|
path="/media/frigate/recordings/front_door/2024-01-01/00.00.mp4",
|
||||||
|
camera="front_door",
|
||||||
|
start_time=100,
|
||||||
|
end_time=110,
|
||||||
|
duration=10,
|
||||||
|
motion=1,
|
||||||
|
segment_size=100,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
response = client.get("/recordings/storage")
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
# Backward-compatible top-level camera map
|
||||||
|
assert payload["front_door"]["usage"] == 100
|
||||||
|
|
||||||
|
# New structured response
|
||||||
|
assert "cameras" in payload
|
||||||
|
assert "recording_roots" in payload
|
||||||
|
assert payload["cameras"]["front_door"]["usage"] == 100
|
||||||
|
|
||||||
|
roots = {root["path"]: root for root in payload["recording_roots"]}
|
||||||
|
assert len(roots) == 1
|
||||||
|
assert roots["/media/frigate/recordings"]["is_default"] is True
|
||||||
|
assert roots["/media/frigate/recordings"]["cameras"] == ["front_door"]
|
||||||
|
assert roots["/media/frigate/recordings"]["configured_cameras"] == [
|
||||||
|
"front_door"
|
||||||
|
]
|
||||||
|
assert (
|
||||||
|
roots["/media/frigate/recordings"]["camera_usages"]["front_door"]["usage"]
|
||||||
|
== 100
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recordings_storage_mixed_default_and_custom_roots(self):
|
||||||
|
self.minimal_config["cameras"]["back_yard"] = {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
"path": "/mnt/slow-recordings",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.test_stats["service"]["storage"]["/mnt/slow-recordings"] = {
|
||||||
|
"free": 3000,
|
||||||
|
"mount_type": "ext4",
|
||||||
|
"total": 4000,
|
||||||
|
"used": 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
app = self._build_app()
|
||||||
|
|
||||||
|
Recordings.insert_many(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "front_default_1",
|
||||||
|
"path": "/media/frigate/recordings/front_door/2024-01-01/00.00.mp4",
|
||||||
|
"camera": "front_door",
|
||||||
|
"start_time": 100,
|
||||||
|
"end_time": 110,
|
||||||
|
"duration": 10,
|
||||||
|
"motion": 1,
|
||||||
|
"segment_size": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "back_custom_1",
|
||||||
|
"path": "/mnt/slow-recordings/back_yard/2024-01-01/00.00.mp4",
|
||||||
|
"camera": "back_yard",
|
||||||
|
"start_time": 200,
|
||||||
|
"end_time": 210,
|
||||||
|
"duration": 10,
|
||||||
|
"motion": 1,
|
||||||
|
"segment_size": 250,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
payload = client.get("/recordings/storage").json()
|
||||||
|
|
||||||
|
roots = {root["path"]: root for root in payload["recording_roots"]}
|
||||||
|
assert len(roots) == 2
|
||||||
|
assert roots["/media/frigate/recordings"]["recordings_size"] == 100
|
||||||
|
assert roots["/mnt/slow-recordings"]["recordings_size"] == 250
|
||||||
|
assert roots["/mnt/slow-recordings"]["is_default"] is False
|
||||||
|
assert roots["/mnt/slow-recordings"]["cameras"] == ["back_yard"]
|
||||||
|
|
||||||
|
def test_recordings_storage_multiple_cameras_share_custom_root(self):
|
||||||
|
self.minimal_config["cameras"]["back_yard"] = {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
"path": "/mnt/shared-recordings",
|
||||||
|
}
|
||||||
|
self.minimal_config["cameras"]["garage"] = {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
"path": "/mnt/shared-recordings",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.test_stats["service"]["storage"]["/mnt/shared-recordings"] = {
|
||||||
|
"free": 800,
|
||||||
|
"mount_type": "ext4",
|
||||||
|
"total": 2000,
|
||||||
|
"used": 1200,
|
||||||
|
}
|
||||||
|
|
||||||
|
app = self._build_app()
|
||||||
|
|
||||||
|
Recordings.insert_many(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "back_1",
|
||||||
|
"path": "/mnt/shared-recordings/back_yard/2024-01-01/00.00.mp4",
|
||||||
|
"camera": "back_yard",
|
||||||
|
"start_time": 100,
|
||||||
|
"end_time": 110,
|
||||||
|
"duration": 10,
|
||||||
|
"motion": 1,
|
||||||
|
"segment_size": 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "garage_1",
|
||||||
|
"path": "/mnt/shared-recordings/garage/2024-01-01/00.00.mp4",
|
||||||
|
"camera": "garage",
|
||||||
|
"start_time": 200,
|
||||||
|
"end_time": 210,
|
||||||
|
"duration": 10,
|
||||||
|
"motion": 1,
|
||||||
|
"segment_size": 500,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
payload = client.get("/recordings/storage").json()
|
||||||
|
|
||||||
|
shared_root = next(
|
||||||
|
root
|
||||||
|
for root in payload["recording_roots"]
|
||||||
|
if root["path"] == "/mnt/shared-recordings"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert shared_root["recordings_size"] == 800
|
||||||
|
assert shared_root["cameras"] == ["back_yard", "garage"]
|
||||||
|
assert set(shared_root["camera_usages"].keys()) == {"back_yard", "garage"}
|
||||||
|
|
||||||
|
def test_recordings_storage_historical_path_migration_splits_usage_by_db_path(self):
|
||||||
|
self.minimal_config["cameras"]["front_door"]["path"] = "/mnt/new-root"
|
||||||
|
|
||||||
|
self.test_stats["service"]["storage"]["/mnt/new-root"] = {
|
||||||
|
"free": 700,
|
||||||
|
"mount_type": "ext4",
|
||||||
|
"total": 1000,
|
||||||
|
"used": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
app = self._build_app()
|
||||||
|
|
||||||
|
Recordings.insert_many(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "front_old_root",
|
||||||
|
"path": "/media/frigate/recordings/front_door/2024-01-01/00.00.mp4",
|
||||||
|
"camera": "front_door",
|
||||||
|
"start_time": 100,
|
||||||
|
"end_time": 110,
|
||||||
|
"duration": 10,
|
||||||
|
"motion": 1,
|
||||||
|
"segment_size": 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "front_new_root",
|
||||||
|
"path": "/mnt/new-root/front_door/2024-02-01/00.00.mp4",
|
||||||
|
"camera": "front_door",
|
||||||
|
"start_time": 200,
|
||||||
|
"end_time": 210,
|
||||||
|
"duration": 10,
|
||||||
|
"motion": 1,
|
||||||
|
"segment_size": 80,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
payload = client.get("/recordings/storage").json()
|
||||||
|
|
||||||
|
roots = {root["path"]: root for root in payload["recording_roots"]}
|
||||||
|
|
||||||
|
# Usage attribution is derived from Recordings.path, not current camera config.
|
||||||
|
assert roots["/media/frigate/recordings"]["recordings_size"] == 120
|
||||||
|
assert roots["/mnt/new-root"]["recordings_size"] == 80
|
||||||
|
|
||||||
|
assert roots["/media/frigate/recordings"]["camera_usages"]["front_door"][
|
||||||
|
"usage"
|
||||||
|
] == 120
|
||||||
|
assert roots["/mnt/new-root"]["camera_usages"]["front_door"]["usage"] == 80
|
||||||
|
|
||||||
|
# Config metadata still reflects current root assignment.
|
||||||
|
assert roots["/media/frigate/recordings"]["configured_cameras"] == []
|
||||||
|
assert roots["/mnt/new-root"]["configured_cameras"] == ["front_door"]
|
||||||
1
web/__test__/test-setup.ts
Normal file
1
web/__test__/test-setup.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
@ -128,11 +128,15 @@
|
|||||||
"storage": {
|
"storage": {
|
||||||
"title": "Storage",
|
"title": "Storage",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"title": "Recordings",
|
"title": "Recordings",
|
||||||
"tips": "This value represents the total storage used by the recordings in Frigate's database. Frigate does not track storage usage for all files on your disk.",
|
"tips": "This value represents the total storage used by the recordings in Frigate's database. Frigate does not track storage usage for all files on your disk.",
|
||||||
"earliestRecording": "Earliest recording available:"
|
"earliestRecording": "Earliest recording available:",
|
||||||
},
|
"roots": "Recording Roots",
|
||||||
|
"nonDefault": "Non-default root",
|
||||||
|
"rootSummary": "Disk used: {{used}} MiB • Free: {{free}} MiB • Usage: {{usage_percent}}%",
|
||||||
|
"rootCameras": "Cameras: {{cameras}}"
|
||||||
|
},
|
||||||
"shm": {
|
"shm": {
|
||||||
"title": "SHM (shared memory) allocation",
|
"title": "SHM (shared memory) allocation",
|
||||||
"warning": "The current SHM size of {{total}}MB is too small. Increase it to at least {{min_shm}}MB."
|
"warning": "The current SHM size of {{total}}MB is too small. Increase it to at least {{min_shm}}MB."
|
||||||
|
|||||||
83
web/src/components/storage/RecordingsRoots.tsx
Normal file
83
web/src/components/storage/RecordingsRoots.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { StorageGraph } from "@/components/graph/StorageGraph";
|
||||||
|
import { getUnitSize } from "@/utils/storageUtil";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export type RootCameraStorage = {
|
||||||
|
[key: string]: {
|
||||||
|
bandwidth: number;
|
||||||
|
usage: number;
|
||||||
|
usage_percent: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecordingRootStorage = {
|
||||||
|
path: string;
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
free: number;
|
||||||
|
usage_percent: number;
|
||||||
|
recordings_size: number;
|
||||||
|
cameras: string[];
|
||||||
|
camera_usages: RootCameraStorage;
|
||||||
|
is_default: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecordingsRoots({ roots }: { roots: RecordingRootStorage[] }) {
|
||||||
|
const { t } = useTranslation(["views/system"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{roots.map((root) => (
|
||||||
|
<div
|
||||||
|
key={root.path}
|
||||||
|
className={`rounded-lg bg-background_alt p-2.5 md:rounded-2xl ${
|
||||||
|
root.is_default ? "" : "border border-primary/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="break-all text-sm font-medium">{root.path}</div>
|
||||||
|
{!root.is_default && (
|
||||||
|
<div className="rounded-md bg-primary/15 px-2 py-1 text-xs text-primary">
|
||||||
|
{t("storage.recordings.nonDefault")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StorageGraph
|
||||||
|
graphId={`recordings-root-${root.path}`}
|
||||||
|
used={root.recordings_size}
|
||||||
|
total={root.total}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-xs text-primary-variant">
|
||||||
|
{t("storage.recordings.rootSummary", {
|
||||||
|
used: root.used,
|
||||||
|
free: root.free,
|
||||||
|
usage_percent: root.usage_percent.toFixed(2),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{t("storage.recordings.rootCameras", {
|
||||||
|
cameras:
|
||||||
|
root.cameras.join(", ").replaceAll("_", " ") ||
|
||||||
|
t("none", { ns: "common" }),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{Object.entries(root.camera_usages).map(([camera, usage]) => (
|
||||||
|
<div
|
||||||
|
key={`${root.path}-${camera}`}
|
||||||
|
className="flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<span className="smart-capitalize">
|
||||||
|
{camera.replaceAll("_", " ")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{getUnitSize(usage.usage)} ({usage.usage_percent.toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { RecordingsRoots, type RecordingRootStorage } from "../RecordingsRoots";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: Record<string, string>) => {
|
||||||
|
if (key === "storage.recordings.nonDefault") return "Non-default root";
|
||||||
|
if (key === "storage.recordings.rootSummary") {
|
||||||
|
return `Disk used: ${opts?.used} MiB • Free: ${opts?.free} MiB • Usage: ${opts?.usage_percent}%`;
|
||||||
|
}
|
||||||
|
if (key === "storage.recordings.rootCameras") {
|
||||||
|
return `Cameras: ${opts?.cameras}`;
|
||||||
|
}
|
||||||
|
if (key === "none") return "None";
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("RecordingsRoots", () => {
|
||||||
|
it("renders multiple roots and per-camera usage", () => {
|
||||||
|
const roots: RecordingRootStorage[] = [
|
||||||
|
{
|
||||||
|
path: "/media/frigate/recordings",
|
||||||
|
total: 1000,
|
||||||
|
used: 700,
|
||||||
|
free: 300,
|
||||||
|
usage_percent: 70,
|
||||||
|
recordings_size: 600,
|
||||||
|
cameras: ["front_door"],
|
||||||
|
is_default: true,
|
||||||
|
camera_usages: {
|
||||||
|
front_door: { bandwidth: 5, usage: 600, usage_percent: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/mnt/custom-recordings",
|
||||||
|
total: 2000,
|
||||||
|
used: 1400,
|
||||||
|
free: 600,
|
||||||
|
usage_percent: 70,
|
||||||
|
recordings_size: 800,
|
||||||
|
cameras: ["back_yard", "garage"],
|
||||||
|
is_default: false,
|
||||||
|
camera_usages: {
|
||||||
|
back_yard: { bandwidth: 4, usage: 300, usage_percent: 37.5 },
|
||||||
|
garage: { bandwidth: 6, usage: 500, usage_percent: 62.5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(<RecordingsRoots roots={roots} />);
|
||||||
|
|
||||||
|
expect(html).toContain("/media/frigate/recordings");
|
||||||
|
expect(html).toContain("/mnt/custom-recordings");
|
||||||
|
expect(html).toContain("Non-default root");
|
||||||
|
expect(html).toContain("Cameras: back yard, garage");
|
||||||
|
expect(html).toContain("garage");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,9 @@
|
|||||||
import { CombinedStorageGraph } from "@/components/graph/CombinedStorageGraph";
|
import { CombinedStorageGraph } from "@/components/graph/CombinedStorageGraph";
|
||||||
import { StorageGraph } from "@/components/graph/StorageGraph";
|
import { StorageGraph } from "@/components/graph/StorageGraph";
|
||||||
|
import {
|
||||||
|
RecordingRootStorage,
|
||||||
|
RecordingsRoots,
|
||||||
|
} from "@/components/storage/RecordingsRoots";
|
||||||
import { FrigateStats } from "@/types/stats";
|
import { FrigateStats } from "@/types/stats";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
@ -27,13 +31,21 @@ type CameraStorage = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RecordingsStorageResponse = CameraStorage & {
|
||||||
|
cameras?: CameraStorage;
|
||||||
|
recording_roots?: RecordingRootStorage[];
|
||||||
|
__recording_roots?: RecordingRootStorage[];
|
||||||
|
};
|
||||||
|
|
||||||
type StorageMetricsProps = {
|
type StorageMetricsProps = {
|
||||||
setLastUpdated: (last: number) => void;
|
setLastUpdated: (last: number) => void;
|
||||||
};
|
};
|
||||||
export default function StorageMetrics({
|
export default function StorageMetrics({
|
||||||
setLastUpdated,
|
setLastUpdated,
|
||||||
}: StorageMetricsProps) {
|
}: StorageMetricsProps) {
|
||||||
const { data: cameraStorage } = useSWR<CameraStorage>("recordings/storage");
|
const { data: recordingsStorage } = useSWR<RecordingsStorageResponse>(
|
||||||
|
"recordings/storage",
|
||||||
|
);
|
||||||
const { data: stats } = useSWR<FrigateStats>("stats");
|
const { data: stats } = useSWR<FrigateStats>("stats");
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
@ -42,6 +54,27 @@ export default function StorageMetrics({
|
|||||||
const timezone = useTimezone(config);
|
const timezone = useTimezone(config);
|
||||||
const { getLocaleDocUrl } = useDocDomain();
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
|
|
||||||
|
const cameraStorage = useMemo(() => {
|
||||||
|
if (!recordingsStorage) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordingsStorage.cameras) {
|
||||||
|
return recordingsStorage.cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(recordingsStorage).filter(
|
||||||
|
([key]) => key !== "__recording_roots" && key !== "recording_roots",
|
||||||
|
),
|
||||||
|
) as CameraStorage;
|
||||||
|
}, [recordingsStorage]);
|
||||||
|
|
||||||
|
const recordingRoots = useMemo(
|
||||||
|
() => recordingsStorage?.recording_roots ?? recordingsStorage?.__recording_roots ?? [],
|
||||||
|
[recordingsStorage],
|
||||||
|
);
|
||||||
|
|
||||||
const totalStorage = useMemo(() => {
|
const totalStorage = useMemo(() => {
|
||||||
if (!cameraStorage || !stats) {
|
if (!cameraStorage || !stats) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -203,6 +236,10 @@ export default function StorageMetrics({
|
|||||||
totalStorage={totalStorage}
|
totalStorage={totalStorage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 text-sm font-medium text-muted-foreground">
|
||||||
|
{t("storage.recordings.roots")}
|
||||||
|
</div>
|
||||||
|
<RecordingsRoots roots={recordingRoots} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user