mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
FEAT: Storage API & Frontend (#3409)
* Get storage output stats for each camera * Add storage route * Add storage route * Add storage page * Cleanup * Add stats and show more storage * Add tests for mb abbrev util fun * Rewrite storage logic to use storage maintainer and segment sizes * Include storage maintainer for http * Use correct format * Remove utils * Fix tests * Remove total from equation * Multiply by 100 to get percent * Add basic storage info * Fix storage stats * Fix endpoint and ui * Fix formatting
This commit is contained in:
parent
25a7c4ee81
commit
5ad391977e
@ -163,6 +163,7 @@ class FrigateApp:
|
|||||||
self.db,
|
self.db,
|
||||||
self.stats_tracking,
|
self.stats_tracking,
|
||||||
self.detected_frames_processor,
|
self.detected_frames_processor,
|
||||||
|
self.storage_maintainer,
|
||||||
self.plus_api,
|
self.plus_api,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -362,13 +363,13 @@ class FrigateApp:
|
|||||||
self.start_detected_frames_processor()
|
self.start_detected_frames_processor()
|
||||||
self.start_camera_processors()
|
self.start_camera_processors()
|
||||||
self.start_camera_capture_processes()
|
self.start_camera_capture_processes()
|
||||||
|
self.start_storage_maintainer()
|
||||||
self.init_stats()
|
self.init_stats()
|
||||||
self.init_web_server()
|
self.init_web_server()
|
||||||
self.start_event_processor()
|
self.start_event_processor()
|
||||||
self.start_event_cleanup()
|
self.start_event_cleanup()
|
||||||
self.start_recording_maintainer()
|
self.start_recording_maintainer()
|
||||||
self.start_recording_cleanup()
|
self.start_recording_cleanup()
|
||||||
self.start_storage_maintainer()
|
|
||||||
self.start_stats_emitter()
|
self.start_stats_emitter()
|
||||||
self.start_watchdog()
|
self.start_watchdog()
|
||||||
# self.zeroconf = broadcast_zeroconf(self.config.mqtt.client_id)
|
# self.zeroconf = broadcast_zeroconf(self.config.mqtt.client_id)
|
||||||
|
@ -27,12 +27,12 @@ from flask import (
|
|||||||
from peewee import SqliteDatabase, operator, fn, DoesNotExist
|
from peewee import SqliteDatabase, operator, fn, DoesNotExist
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.config import CameraConfig
|
from frigate.const import CLIPS_DIR, RECORD_DIR
|
||||||
from frigate.const import CLIPS_DIR
|
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings
|
||||||
from frigate.object_processing import TrackedObject
|
from frigate.object_processing import TrackedObject
|
||||||
from frigate.stats import stats_snapshot
|
from frigate.stats import stats_snapshot
|
||||||
from frigate.util import clean_camera_user_pass, ffprobe_stream, vainfo_hwaccel
|
from frigate.util import clean_camera_user_pass, ffprobe_stream, vainfo_hwaccel
|
||||||
|
from frigate.storage import StorageMaintainer
|
||||||
from frigate.version import VERSION
|
from frigate.version import VERSION
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -45,6 +45,7 @@ def create_app(
|
|||||||
database: SqliteDatabase,
|
database: SqliteDatabase,
|
||||||
stats_tracking,
|
stats_tracking,
|
||||||
detected_frames_processor,
|
detected_frames_processor,
|
||||||
|
storage_maintainer: StorageMaintainer,
|
||||||
plus_api,
|
plus_api,
|
||||||
):
|
):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@ -62,6 +63,7 @@ def create_app(
|
|||||||
app.frigate_config = frigate_config
|
app.frigate_config = frigate_config
|
||||||
app.stats_tracking = stats_tracking
|
app.stats_tracking = stats_tracking
|
||||||
app.detected_frames_processor = detected_frames_processor
|
app.detected_frames_processor = detected_frames_processor
|
||||||
|
app.storage_maintainer = storage_maintainer
|
||||||
app.plus_api = plus_api
|
app.plus_api = plus_api
|
||||||
app.camera_error_image = None
|
app.camera_error_image = None
|
||||||
|
|
||||||
@ -690,6 +692,26 @@ def latest_frame(camera_name):
|
|||||||
return "Camera named {} not found".format(camera_name), 404
|
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
|
# return hourly summary for recordings of camera
|
||||||
@bp.route("/<camera_name>/recordings/summary")
|
@bp.route("/<camera_name>/recordings/summary")
|
||||||
def recordings_summary(camera_name):
|
def recordings_summary(camera_name):
|
||||||
|
@ -60,6 +60,26 @@ class StorageMaintainer(threading.Thread):
|
|||||||
self.camera_storage_stats[camera]["bandwidth"] = bandwidth
|
self.camera_storage_stats[camera]["bandwidth"] = bandwidth
|
||||||
logger.debug(f"{camera} has a bandwidth of {bandwidth} MB/hr.")
|
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:
|
def check_storage_needs_cleanup(self) -> bool:
|
||||||
"""Return if storage needs cleanup."""
|
"""Return if storage needs cleanup."""
|
||||||
# currently runs cleanup if less than 1 hour of space is left
|
# currently runs cleanup if less than 1 hour of space is left
|
||||||
|
@ -114,7 +114,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_get_event_list(self):
|
def test_get_event_list(self):
|
||||||
app = create_app(
|
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"
|
id = "123456.random"
|
||||||
id2 = "7890.random"
|
id2 = "7890.random"
|
||||||
@ -143,7 +143,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_get_good_event(self):
|
def test_get_good_event(self):
|
||||||
app = create_app(
|
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"
|
id = "123456.random"
|
||||||
|
|
||||||
@ -157,7 +157,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_get_bad_event(self):
|
def test_get_bad_event(self):
|
||||||
app = create_app(
|
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"
|
id = "123456.random"
|
||||||
bad_id = "654321.other"
|
bad_id = "654321.other"
|
||||||
@ -170,7 +170,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_delete_event(self):
|
def test_delete_event(self):
|
||||||
app = create_app(
|
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"
|
id = "123456.random"
|
||||||
|
|
||||||
@ -185,7 +185,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_event_retention(self):
|
def test_event_retention(self):
|
||||||
app = create_app(
|
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"
|
id = "123456.random"
|
||||||
|
|
||||||
@ -204,7 +204,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_set_delete_sub_label(self):
|
def test_set_delete_sub_label(self):
|
||||||
app = create_app(
|
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"
|
id = "123456.random"
|
||||||
sub_label = "sub"
|
sub_label = "sub"
|
||||||
@ -232,7 +232,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
def test_sub_label_list(self):
|
def test_sub_label_list(self):
|
||||||
app = create_app(
|
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"
|
id = "123456.random"
|
||||||
sub_label = "sub"
|
sub_label = "sub"
|
||||||
@ -254,6 +254,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -268,6 +269,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -285,6 +287,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
mock_stats.return_value = self.test_stats
|
mock_stats.return_value = self.test_stats
|
||||||
|
BIN
test.db-journal
BIN
test.db-journal
Binary file not shown.
@ -44,6 +44,7 @@ export default function Sidebar() {
|
|||||||
</Match>
|
</Match>
|
||||||
{birdseye?.enabled ? <Destination href="/birdseye" text="Birdseye" /> : null}
|
{birdseye?.enabled ? <Destination href="/birdseye" text="Birdseye" /> : null}
|
||||||
<Destination href="/events" text="Events" />
|
<Destination href="/events" text="Events" />
|
||||||
|
<Destination href="/storage" text="Storage" />
|
||||||
<Destination href="/system" text="System" />
|
<Destination href="/system" text="System" />
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-grow" />
|
<div className="flex flex-grow" />
|
||||||
|
@ -35,6 +35,7 @@ export default function App() {
|
|||||||
path="/recording/:camera/:date?/:hour?/:minute?/:second?"
|
path="/recording/:camera/:date?/:hour?/:minute?/:second?"
|
||||||
getComponent={Routes.getRecording}
|
getComponent={Routes.getRecording}
|
||||||
/>
|
/>
|
||||||
|
<AsyncRoute path="/storage" getComponent={Routes.getStorage} />
|
||||||
<AsyncRoute path="/system" getComponent={Routes.getSystem} />
|
<AsyncRoute path="/system" getComponent={Routes.getSystem} />
|
||||||
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
|
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
|
||||||
<Cameras default path="/" />
|
<Cameras default path="/" />
|
||||||
|
109
web/src/routes/Storage.jsx
Normal file
109
web/src/routes/Storage.jsx
Normal file
@ -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 (
|
||||||
|
<div className="space-y-4 p-2 px-4">
|
||||||
|
<Heading>Storage</Heading>
|
||||||
|
|
||||||
|
{(!service || !storage) ? (
|
||||||
|
<div>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Fragment>
|
||||||
|
<Heading size="lg">Overview</Heading>
|
||||||
|
<div data-testid="detectors" className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
|
||||||
|
<div className="text-lg flex justify-between p-4">Data</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<Table className="w-full">
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Location</Th>
|
||||||
|
<Th>Used MB</Th>
|
||||||
|
<Th>Total MB</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
<Tr>
|
||||||
|
<Td>Snapshots & Recordings</Td>
|
||||||
|
<Td>{service['storage']['/media/frigate/recordings']['used']}</Td>
|
||||||
|
<Td>{service['storage']['/media/frigate/recordings']['total']}</Td>
|
||||||
|
</Tr>
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
|
||||||
|
<div className="text-lg flex justify-between p-4">Memory</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<Table className="w-full">
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Location</Th>
|
||||||
|
<Th>Used MB</Th>
|
||||||
|
<Th>Total MB</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
<Tr>
|
||||||
|
<Td>/dev/shm</Td>
|
||||||
|
<Td>{service['storage']['/dev/shm']['used']}</Td>
|
||||||
|
<Td>{service['storage']['/dev/shm']['total']}</Td>
|
||||||
|
</Tr>
|
||||||
|
<Tr>
|
||||||
|
<Td>/tmp/cache</Td>
|
||||||
|
<Td>{service['storage']['/tmp/cache']['used']}</Td>
|
||||||
|
<Td>{service['storage']['/tmp/cache']['total']}</Td>
|
||||||
|
</Tr>
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Heading size="lg">Cameras</Heading>
|
||||||
|
<div data-testid="detectors" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
|
||||||
|
{Object.entries(storage).map(([name, camera]) => (
|
||||||
|
<div key={name} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
|
||||||
|
<div className="text-lg flex justify-between p-4">{name}</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<Table className="w-full">
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Usage</Th>
|
||||||
|
<Th>Stream Bandwidth</Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
<Tr>
|
||||||
|
<Td>{Math.round(camera['usage_percent'] ?? 0)}%</Td>
|
||||||
|
<Td>{camera['bandwidth']} MB/hr</Td>
|
||||||
|
</Tr>
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -33,6 +33,11 @@ export async function getSystem(_url, _cb, _props) {
|
|||||||
return module.default;
|
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) {
|
export async function getStyleGuide(_url, _cb, _props) {
|
||||||
const module = await import('./StyleGuide.jsx');
|
const module = await import('./StyleGuide.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
|
Loading…
Reference in New Issue
Block a user