diff --git a/frigate/app.py b/frigate/app.py index f62d4a78a..e1cbef35a 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -163,6 +163,7 @@ class FrigateApp: self.db, self.stats_tracking, self.detected_frames_processor, + self.storage_maintainer, self.plus_api, ) @@ -362,13 +363,13 @@ class FrigateApp: self.start_detected_frames_processor() self.start_camera_processors() self.start_camera_capture_processes() + self.start_storage_maintainer() self.init_stats() self.init_web_server() self.start_event_processor() self.start_event_cleanup() self.start_recording_maintainer() self.start_recording_cleanup() - self.start_storage_maintainer() self.start_stats_emitter() self.start_watchdog() # self.zeroconf = broadcast_zeroconf(self.config.mqtt.client_id) diff --git a/frigate/http.py b/frigate/http.py index 57517f168..a1f9aea4e 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -27,12 +27,12 @@ from flask import ( from peewee import SqliteDatabase, operator, fn, DoesNotExist from playhouse.shortcuts import model_to_dict -from frigate.config import CameraConfig -from frigate.const import CLIPS_DIR +from frigate.const import CLIPS_DIR, RECORD_DIR from frigate.models import Event, Recordings from frigate.object_processing import TrackedObject from frigate.stats import stats_snapshot from frigate.util import clean_camera_user_pass, ffprobe_stream, vainfo_hwaccel +from frigate.storage import StorageMaintainer from frigate.version import VERSION logger = logging.getLogger(__name__) @@ -45,6 +45,7 @@ def create_app( database: SqliteDatabase, stats_tracking, detected_frames_processor, + storage_maintainer: StorageMaintainer, plus_api, ): app = Flask(__name__) @@ -62,6 +63,7 @@ def create_app( app.frigate_config = frigate_config app.stats_tracking = stats_tracking app.detected_frames_processor = detected_frames_processor + app.storage_maintainer = storage_maintainer app.plus_api = plus_api app.camera_error_image = None @@ -690,6 +692,26 @@ def latest_frame(camera_name): return "Camera named {} not found".format(camera_name), 404 +@bp.route("/recordings/storage", methods=["GET"]) +def get_recordings_storage_usage(): + recording_stats = stats_snapshot( + current_app.frigate_config, current_app.stats_tracking + )["service"]["storage"][RECORD_DIR] + total_mb = recording_stats["total"] + + camera_usages: dict[ + str, dict + ] = current_app.storage_maintainer.calculate_camera_usages() + + for camera_name in camera_usages.keys(): + if camera_usages.get(camera_name, {}).get("usage"): + camera_usages[camera_name]["usage_percent"] = ( + camera_usages.get(camera_name, {}).get("usage", 0) / total_mb + ) * 100 + + return jsonify(camera_usages) + + # return hourly summary for recordings of camera @bp.route("//recordings/summary") def recordings_summary(camera_name): diff --git a/frigate/storage.py b/frigate/storage.py index 2720b7aca..1ac67a00c 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -60,6 +60,26 @@ class StorageMaintainer(threading.Thread): self.camera_storage_stats[camera]["bandwidth"] = bandwidth logger.debug(f"{camera} has a bandwidth of {bandwidth} MB/hr.") + def calculate_camera_usages(self) -> dict[str, dict]: + """Calculate the storage usage of each camera.""" + usages: dict[str, dict] = {} + + for camera in self.config.cameras.keys(): + camera_storage = ( + Recordings.select(fn.SUM(Recordings.segment_size)) + .where(Recordings.camera == camera, Recordings.segment_size != 0) + .scalar() + ) + + usages[camera] = { + "usage": camera_storage, + "bandwidth": self.camera_storage_stats.get(camera, {}).get( + "bandwidth", 0 + ), + } + + return usages + def check_storage_needs_cleanup(self) -> bool: """Return if storage needs cleanup.""" # currently runs cleanup if less than 1 hour of space is left diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 04acdd681..12105926e 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -114,7 +114,7 @@ class TestHttp(unittest.TestCase): def test_get_event_list(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, PlusApi() + FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() ) id = "123456.random" id2 = "7890.random" @@ -143,7 +143,7 @@ class TestHttp(unittest.TestCase): def test_get_good_event(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, PlusApi() + FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() ) id = "123456.random" @@ -157,7 +157,7 @@ class TestHttp(unittest.TestCase): def test_get_bad_event(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, PlusApi() + FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() ) id = "123456.random" bad_id = "654321.other" @@ -170,7 +170,7 @@ class TestHttp(unittest.TestCase): def test_delete_event(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, PlusApi() + FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() ) id = "123456.random" @@ -185,7 +185,7 @@ class TestHttp(unittest.TestCase): def test_event_retention(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, PlusApi() + FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() ) id = "123456.random" @@ -204,7 +204,7 @@ class TestHttp(unittest.TestCase): def test_set_delete_sub_label(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, PlusApi() + FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() ) id = "123456.random" sub_label = "sub" @@ -232,7 +232,7 @@ class TestHttp(unittest.TestCase): def test_sub_label_list(self): app = create_app( - FrigateConfig(**self.minimal_config), self.db, None, None, PlusApi() + FrigateConfig(**self.minimal_config), self.db, None, None, None, PlusApi() ) id = "123456.random" sub_label = "sub" @@ -254,6 +254,7 @@ class TestHttp(unittest.TestCase): self.db, None, None, + None, PlusApi(), ) @@ -268,6 +269,7 @@ class TestHttp(unittest.TestCase): self.db, None, None, + None, PlusApi(), ) id = "123456.random" @@ -285,6 +287,7 @@ class TestHttp(unittest.TestCase): self.db, None, None, + None, PlusApi(), ) mock_stats.return_value = self.test_stats diff --git a/test.db-journal b/test.db-journal deleted file mode 100644 index 3649988aa..000000000 Binary files a/test.db-journal and /dev/null differ diff --git a/web/src/Sidebar.jsx b/web/src/Sidebar.jsx index 040ec6f2d..c7d937da9 100644 --- a/web/src/Sidebar.jsx +++ b/web/src/Sidebar.jsx @@ -44,6 +44,7 @@ export default function Sidebar() { {birdseye?.enabled ? : null} +
diff --git a/web/src/app.tsx b/web/src/app.tsx index 09d9bbfb8..536cc82e3 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -35,6 +35,7 @@ export default function App() { path="/recording/:camera/:date?/:hour?/:minute?/:second?" getComponent={Routes.getRecording} /> + diff --git a/web/src/routes/Storage.jsx b/web/src/routes/Storage.jsx new file mode 100644 index 000000000..afb9cbe2c --- /dev/null +++ b/web/src/routes/Storage.jsx @@ -0,0 +1,109 @@ +import { h, Fragment } from 'preact'; +import ActivityIndicator from '../components/ActivityIndicator'; +import Heading from '../components/Heading'; +import { useWs } from '../api/ws'; +import useSWR from 'swr'; +import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table'; + +const emptyObject = Object.freeze({}); + +export default function Storage() { + const { data: storage } = useSWR('recordings/storage'); + + const { + value: { payload: stats }, + } = useWs('stats'); + const { data: initialStats } = useSWR('stats'); + + const { service } = stats || initialStats || emptyObject; + + return ( +
+ Storage + + {(!service || !storage) ? ( +
+ +
+ ) : ( + + Overview +
+
+
Data
+
+ + + + + + + + + + + + + + + +
LocationUsed MBTotal MB
Snapshots & Recordings{service['storage']['/media/frigate/recordings']['used']}{service['storage']['/media/frigate/recordings']['total']}
+
+
+
+
Memory
+
+ + + + + + + + + + + + + + + + + + + + +
LocationUsed MBTotal MB
/dev/shm{service['storage']['/dev/shm']['used']}{service['storage']['/dev/shm']['total']}
/tmp/cache{service['storage']['/tmp/cache']['used']}{service['storage']['/tmp/cache']['total']}
+
+
+
+ + Cameras +
+ {Object.entries(storage).map(([name, camera]) => ( +
+
{name}
+
+ + + + + + + + + + + + + +
UsageStream Bandwidth
{Math.round(camera['usage_percent'] ?? 0)}%{camera['bandwidth']} MB/hr
+
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/web/src/routes/index.js b/web/src/routes/index.js index 1a8af6384..39c9ed05c 100644 --- a/web/src/routes/index.js +++ b/web/src/routes/index.js @@ -33,6 +33,11 @@ export async function getSystem(_url, _cb, _props) { return module.default; } +export async function getStorage(_url, _cb, _props) { + const module = await import('./Storage.jsx'); + return module.default; +} + export async function getStyleGuide(_url, _cb, _props) { const module = await import('./StyleGuide.jsx'); return module.default;