mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-18 06:08:22 +03:00
Fix recordings root attribution to use DB paths
This commit is contained in:
parent
ba045ab3cf
commit
9f5d93e723
@ -37,15 +37,16 @@ router = APIRouter(tags=[Tags.recordings])
|
||||
def get_recordings_storage_usage(request: Request):
|
||||
storage_stats = request.app.stats_emitter.get_latest_stats()["service"]["storage"]
|
||||
|
||||
recording_paths = request.app.frigate_config.get_recordings_paths()
|
||||
recording_stats = [storage_stats.get(path, {}) for path in recording_paths]
|
||||
configured_recording_paths = request.app.frigate_config.get_recordings_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)
|
||||
|
||||
if total_mb == 0:
|
||||
return JSONResponse({})
|
||||
|
||||
camera_usages: dict[str, dict] = (
|
||||
request.app.storage_maintainer.calculate_camera_usages()
|
||||
camera_usages: dict[str, dict] = 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():
|
||||
@ -54,7 +55,66 @@ def get_recordings_storage_usage(request: Request):
|
||||
camera_usages.get(camera_name, {}).get("usage", 0) / total_mb
|
||||
) * 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())])
|
||||
|
||||
@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from peewee import SQL, fn
|
||||
|
||||
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.util.builtin import clear_and_unlink
|
||||
|
||||
@ -103,6 +103,88 @@ class StorageMaintainer(threading.Thread):
|
||||
|
||||
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]:
|
||||
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": {
|
||||
"title": "Storage",
|
||||
"overview": "Overview",
|
||||
"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.",
|
||||
"earliestRecording": "Earliest recording available:"
|
||||
},
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "SHM (shared memory) allocation",
|
||||
"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 { StorageGraph } from "@/components/graph/StorageGraph";
|
||||
import {
|
||||
RecordingRootStorage,
|
||||
RecordingsRoots,
|
||||
} from "@/components/storage/RecordingsRoots";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
@ -27,13 +31,21 @@ type CameraStorage = {
|
||||
};
|
||||
};
|
||||
|
||||
type RecordingsStorageResponse = CameraStorage & {
|
||||
cameras?: CameraStorage;
|
||||
recording_roots?: RecordingRootStorage[];
|
||||
__recording_roots?: RecordingRootStorage[];
|
||||
};
|
||||
|
||||
type StorageMetricsProps = {
|
||||
setLastUpdated: (last: number) => void;
|
||||
};
|
||||
export default function StorageMetrics({
|
||||
setLastUpdated,
|
||||
}: StorageMetricsProps) {
|
||||
const { data: cameraStorage } = useSWR<CameraStorage>("recordings/storage");
|
||||
const { data: recordingsStorage } = useSWR<RecordingsStorageResponse>(
|
||||
"recordings/storage",
|
||||
);
|
||||
const { data: stats } = useSWR<FrigateStats>("stats");
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
@ -42,6 +54,27 @@ export default function StorageMetrics({
|
||||
const timezone = useTimezone(config);
|
||||
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(() => {
|
||||
if (!cameraStorage || !stats) {
|
||||
return undefined;
|
||||
@ -203,6 +236,10 @@ export default function StorageMetrics({
|
||||
totalStorage={totalStorage}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 text-sm font-medium text-muted-foreground">
|
||||
{t("storage.recordings.roots")}
|
||||
</div>
|
||||
<RecordingsRoots roots={recordingRoots} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user