report null instead of 0 for a stream with no cached bandwidth sample

This commit is contained in:
Josh Hawkins
2026-08-17 09:06:54 -05:00
parent aaf96522f4
commit 5437192289
4 changed files with 55 additions and 7 deletions
+4 -2
View File
@@ -156,11 +156,13 @@ class StorageMaintainer(threading.Thread):
"bandwidth", 0
),
# only streams with segments on disk are reported, so a camera
# keeps its sub entry until sub retention expires those segments
# keeps its sub entry until sub retention expires those segments.
# bandwidth is null rather than 0 when the cache holds no sample
# for the stream, since 0 would claim it writes nothing
"streams": {
stream_type: {
"usage": stream_usages[stream_type],
"bandwidth": stream_bandwidths.get(stream_type, 0),
"bandwidth": stream_bandwidths.get(stream_type),
}
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB)
if stream_usages.get(stream_type)
+43
View File
@@ -283,6 +283,49 @@ class TestHttp(unittest.TestCase):
== MAX_CALCULATED_BANDWIDTH
)
def test_stream_bandwidth_is_none_without_a_cached_sample(self):
"""A stream that appears after the bandwidth cache freezes has no estimate.
Sub stream recording can be toggled on at runtime, so the cache can hold
a main-only sample while sub segments are already landing on disk.
Reporting 0 there would claim the sub stream costs nothing.
"""
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock())
time_keep = datetime.datetime.now().timestamp()
for i in range(60):
_insert_mock_recording(
f"main_{i}.frontdoor",
os.path.join(self.test_dir, f"main_{i}.tmp"),
time_keep + i * 10,
time_keep + i * 10 + 10,
seg_size=20,
seg_dur=10,
)
# 50 or more segments flips needs_refresh off, freezing the cache
storage.calculate_camera_bandwidth()
assert storage.camera_storage_stats["front_door"]["needs_refresh"] is False
for i in range(60):
_insert_mock_recording(
f"sub_{i}.frontdoor",
os.path.join(self.test_dir, f"sub_{i}.tmp"),
time_keep + 5000 + i * 10,
time_keep + 5000 + i * 10 + 10,
seg_size=2,
seg_dur=10,
stream_type=STREAM_TYPE_SUB,
)
storage.calculate_camera_bandwidth()
streams = storage.calculate_camera_usages()["front_door"]["streams"]
assert streams[STREAM_TYPE_SUB]["usage"] == 120
assert streams[STREAM_TYPE_SUB]["bandwidth"] is None
assert streams[STREAM_TYPE_MAIN]["bandwidth"] == 7200
def test_camera_usages_with_no_recordings(self):
"""A camera with no segments reports zero usage and no streams."""
config = FrigateConfig(**self.minimal_config)
@@ -204,9 +204,12 @@ export function CombinedStorageGraph({
const getStreamSplit = useCallback(
(row: StorageRow, field: keyof StreamStorage) => {
const sub = row.streams?.sub;
const mainValue = row.streams?.main?.[field];
const subValue = row.streams?.sub?.[field];
if (!sub) {
// omit the split entirely when either side is unknown, rather than
// showing a zero that would read as "this stream costs nothing"
if (mainValue == null || subValue == null) {
return null;
}
@@ -216,13 +219,13 @@ export function CombinedStorageGraph({
<span className="text-muted-foreground">
{t("quality.main", { ns: "components/player" })}
</span>{" "}
{getUnitSize(row.streams?.main?.[field] ?? 0)}
{getUnitSize(mainValue)}
</div>
<div>
<span className="text-muted-foreground">
{t("quality.sub", { ns: "components/player" })}
</span>{" "}
{getUnitSize(sub[field])}
{getUnitSize(subValue)}
</div>
</div>
);
+1 -1
View File
@@ -103,7 +103,7 @@ export type StorageStats = {
export type StreamStorage = {
usage: number;
bandwidth: number;
bandwidth: number | null;
};
export type CameraStorage = {