Compare commits

...
2 Commits
Author SHA1 Message Date
Ersa Oktavian RamadanandGitHub 39ac0b4712 Add audio labelmap grouping (#24004)
Allow audio classes to be grouped under a shared configured label.

Keep audio overrides separate from object labels and retain only the highest-scoring grouped detection.

Refs #23967
2026-08-18 07:27:40 -06:00
Josh HawkinsandGitHub ffa6acd609 Show main and sub stream usage separately in Storage Metrics (#24015)
* backend

* frontend

* docs

* test

* report null instead of 0 for a stream with no cached bandwidth sample
2026-08-18 07:25:38 -06:00
16 changed files with 563 additions and 111 deletions
@@ -217,6 +217,8 @@ audio:
- fire_alarm
- speech
- yell
# Optional: Audio label name modifications. These are merged into the standard audio labelmap.
labelmap: {}
# Optional: Filters to configure detection.
filters:
# Label that matches label in listen config.
@@ -114,6 +114,30 @@ audio:
</TabItem>
</ConfigTabs>
#### Grouping Audio Labels
Related audio classes can be grouped under one label by mapping their numeric
class IDs to the same name. Add the grouped name to `listen` and use it for any
corresponding filter:
```yaml
audio:
listen:
- dogs
labelmap:
69: dogs # dog
70: dogs # bark
75: dogs # whimper_dog
filters:
dogs:
threshold: 0.8
```
Class IDs are zero-based indices in
[`audio-labelmap.txt`](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt),
so each ID is one less than the displayed file line number.
Audio label mappings are separate from the object detector's `model.labelmap`.
### Common Audio Labels
The labelmap includes hundreds of sound types. The labels below are the ones most users may find practical, grouped by what they're typically used for. Use the exact label string from the left column in your `listen` config, or search for the label in the Frigate UI directly.
+1 -1
View File
@@ -421,7 +421,7 @@ As a general rule, features that read recordings prefer the main stream and fall
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
| Motion search | Main only |
| Review timeline motion data | Main only |
| Storage usage statistics | Both streams counted |
| Storage usage statistics | Both streams counted, and listed separately per camera |
This table covers only features that read recordings from disk. Tracked object snapshots and thumbnails (the images shown in Explore and sent with notifications, and the images submitted to Frigate+ from a tracked object) are captured live from the `detect` stream as the object is tracked, never from recordings, so sub stream recording does not affect them.
+5
View File
@@ -41,6 +41,11 @@ class AudioConfig(FrigateBaseModel):
title="Listen types",
description="List of audio event types to detect (for example: bark, fire_alarm, speech, yell).",
)
labelmap: dict[int, str] = Field(
default_factory=dict,
title="Audio labelmap customization",
description="Overrides or remapping entries to merge into the standard audio labelmap.",
)
filters: dict[str, AudioFilterConfig] | None = Field(
None,
title="Audio filters",
+29 -7
View File
@@ -210,7 +210,11 @@ class AudioEventMaintainer(threading.Thread):
# per-camera stop signal so a single maintainer can be torn down at
# runtime (e.g. on camera removal) without stopping the whole process
self.camera_stop_event = threading.Event()
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
self.detector = AudioTfl(
stop_event,
self.camera_config.audio.num_threads,
self.camera_config.audio.labelmap,
)
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
self.logger = logging.getLogger(f"audio.{self.camera_config.name}")
@@ -392,7 +396,10 @@ class AudioEventMaintainer(threading.Thread):
while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
# check if there is an updated config
self.config_subscriber.check_for_updates()
updated_topics = self.config_subscriber.check_for_updates()
if CameraConfigUpdateEnum.audio.name in updated_topics:
self.detector.update_labelmap(self.camera_config.audio.labelmap)
enabled = self.camera_config.enabled
if enabled != self.was_enabled:
@@ -451,10 +458,17 @@ class AudioEventMaintainer(threading.Thread):
class AudioTfl:
def __init__(self, stop_event: threading.Event, num_threads: int = 2) -> None:
def __init__(
self,
stop_event: threading.Event,
num_threads: int = 2,
labelmap: dict[int, str] | None = None,
) -> None:
self.stop_event = stop_event
self.num_threads = num_threads
self.labels = load_labels("/audio-labelmap.txt", prefill=521)
self._default_labels = load_labels("/audio-labelmap.txt", prefill=521)
self.labels: dict[int, str] = {}
self.update_labelmap(labelmap or {})
# Suppress TFLite delegate creation messages that bypass Python logging
with suppress_stderr_during("tflite_interpreter_init"):
self.interpreter = Interpreter(
@@ -466,6 +480,10 @@ class AudioTfl:
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
def update_labelmap(self, labelmap: dict[int, str]) -> None:
"""Merge configured label overrides into the default audio labelmap."""
self.labels = {**self._default_labels, **labelmap}
def _detect_raw(self, tensor_input: np.ndarray) -> np.ndarray:
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
self.interpreter.invoke()
@@ -504,10 +522,14 @@ class AudioTfl:
raw_detections = self._detect_raw(tensor_input)
detected_labels: set[str] = set()
for d in raw_detections:
if d[1] < threshold:
break
detections.append(
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5]))
)
label = self.labels[int(d[0])]
if label in detected_labels:
continue
detected_labels.add(label)
detections.append((label, float(d[1]), (d[2], d[3], d[4], d[5])))
return detections
+40 -8
View File
@@ -88,7 +88,7 @@ class StorageMaintainer(threading.Thread):
# type and sum the rates; mixing streams would average small
# sub segments against large main segments and underestimate
# the true write rate
bandwidth = 0.0
bandwidth_by_stream: dict[str, float] = {}
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB):
avg_bw = self._recent_stream_bandwidth(camera, stream_type, 100)
if avg_bw is None:
@@ -99,17 +99,27 @@ class StorageMaintainer(threading.Thread):
camera, stream_type, 1000
)
if avg_bw is not None:
bandwidth += round(avg_bw * 3600, 2)
bandwidth_by_stream[stream_type] = round(avg_bw * 3600, 2)
bandwidth = round(bandwidth, 2)
bandwidth = round(sum(bandwidth_by_stream.values()), 2)
if bandwidth > MAX_CALCULATED_BANDWIDTH:
logger.warning(
f"{camera} has a bandwidth of {bandwidth} MB/hr which exceeds the expected maximum. This typically indicates an issue with the cameras recordings."
)
# scale each stream so the per stream values still sum to
# the clamped total the UI displays alongside them
scale = MAX_CALCULATED_BANDWIDTH / bandwidth
bandwidth_by_stream = {
stream_type: round(value * scale, 2)
for stream_type, value in bandwidth_by_stream.items()
}
bandwidth = MAX_CALCULATED_BANDWIDTH
self.camera_storage_stats[camera]["bandwidth"] = bandwidth
self.camera_storage_stats[camera]["bandwidth_by_stream"] = (
bandwidth_by_stream
)
logger.debug(f"{camera} has a bandwidth of {bandwidth} MiB/hr.")
def calculate_camera_usages(self) -> dict[str, dict]:
@@ -121,20 +131,42 @@ class StorageMaintainer(threading.Thread):
if camera.startswith(REPLAY_CAMERA_PREFIX):
continue
camera_storage = (
Recordings.select(fn.SUM(Recordings.segment_size))
.where(Recordings.camera == camera, Recordings.segment_size != 0)
.scalar()
stream_usages = {
row["stream_type"]: row["usage"] or 0
for row in (
Recordings.select(
Recordings.stream_type,
fn.SUM(Recordings.segment_size).alias("usage"),
)
.where(Recordings.camera == camera, Recordings.segment_size != 0)
.group_by(Recordings.stream_type)
.dicts()
)
}
stream_bandwidths = self.camera_storage_stats.get(camera, {}).get(
"bandwidth_by_stream", {}
)
camera_key = (
getattr(self.config.cameras[camera], "friendly_name", None) or camera
)
usages[camera_key] = {
"usage": camera_storage,
"usage": sum(stream_usages.values()),
"bandwidth": self.camera_storage_stats.get(camera, {}).get(
"bandwidth", 0
),
# only streams with segments on disk are reported, so a camera
# 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),
}
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB)
if stream_usages.get(stream_type)
},
}
return usages
+75
View File
@@ -0,0 +1,75 @@
"""Tests for audio label mapping."""
import threading
import unittest
from unittest.mock import Mock
import numpy as np
from frigate.events.audio import AudioTfl
class TestAudioTfl(unittest.TestCase):
def setUp(self):
self.detector = AudioTfl.__new__(AudioTfl)
self.detector.stop_event = threading.Event()
self.detector._default_labels = {
69: "dog",
70: "bark",
75: "whimper_dog",
117: "dogs",
}
def test_update_labelmap_replaces_and_resets_overrides(self):
self.detector.update_labelmap({69: "dogs", 70: "dogs"})
assert self.detector.labels == {
69: "dogs",
70: "dogs",
75: "whimper_dog",
117: "dogs",
}
self.detector.update_labelmap({75: "whimper"})
assert self.detector.labels == {
69: "dog",
70: "bark",
75: "whimper",
117: "dogs",
}
def test_detect_returns_highest_scoring_detection_for_grouped_label(self):
self.detector.update_labelmap({69: "dogs", 70: "dogs", 75: "dogs"})
self.detector._detect_raw = Mock(
return_value=np.array(
[
[117, 0.95, -1, -1, -1, -1],
[70, 0.9, -1, -1, -1, -1],
[69, 0.8, -1, -1, -1, -1],
[75, 0.7, -1, -1, -1, -1],
],
dtype=np.float32,
)
)
detections = self.detector.detect(np.array([], dtype=np.float32))
assert len(detections) == 1
assert detections[0][0] == "dogs"
self.assertAlmostEqual(detections[0][1], 0.95)
def test_each_dog_audio_label_maps_to_grouped_label(self):
self.detector.update_labelmap({69: "dogs", 70: "dogs", 75: "dogs"})
for class_id in (69, 70, 75):
with self.subTest(class_id=class_id):
self.detector._detect_raw = Mock(
return_value=np.array(
[[class_id, 0.9, -1, -1, -1, -1]], dtype=np.float32
)
)
detections = self.detector.detect(np.array([], dtype=np.float32))
assert len(detections) == 1
assert detections[0][0] == "dogs"
self.assertAlmostEqual(detections[0][1], 0.9)
+22
View File
@@ -1154,6 +1154,28 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config)
assert frigate_config.model.merged_labelmap[7] == "truck"
def test_audio_labelmap_inheritance_is_separate_from_model_labelmap(self):
config = deep_merge(
{
"audio": {"labelmap": {69: "dogs", 70: "dogs"}},
"cameras": {
"back": {
"audio": {"labelmap": {75: "dogs"}},
}
},
},
self.minimal,
)
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].audio.labelmap == {
69: "dogs",
70: "dogs",
75: "dogs",
}
assert frigate_config.model.merged_labelmap[69] != "dogs"
def test_default_labelmap_empty(self):
config = {
"mqtt": {"host": "mqtt"},
+170 -5
View File
@@ -11,8 +11,9 @@ from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.config import FrigateConfig
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
from frigate.models import Event, Recordings
from frigate.storage import StorageMaintainer
from frigate.storage import MAX_CALCULATED_BANDWIDTH, StorageMaintainer
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
@@ -114,8 +115,16 @@ class TestHttp(unittest.TestCase):
)
storage.calculate_camera_bandwidth()
assert storage.camera_storage_stats == {
"front_door": {"bandwidth": 1440, "needs_refresh": True},
"back_door": {"bandwidth": 2880, "needs_refresh": True},
"front_door": {
"bandwidth": 1440,
"bandwidth_by_stream": {STREAM_TYPE_MAIN: 1440},
"needs_refresh": True,
},
"back_door": {
"bandwidth": 2880,
"bandwidth_by_stream": {STREAM_TYPE_MAIN: 2880},
"needs_refresh": True,
},
}
def test_segment_calculations_with_zero_segments(self):
@@ -136,7 +145,11 @@ class TestHttp(unittest.TestCase):
)
storage.calculate_camera_bandwidth()
assert storage.camera_storage_stats == {
"front_door": {"bandwidth": 0, "needs_refresh": True},
"front_door": {
"bandwidth": 0,
"bandwidth_by_stream": {},
"needs_refresh": True,
},
}
def test_segment_calculations_with_recent_zero_segments(self):
@@ -171,9 +184,159 @@ class TestHttp(unittest.TestCase):
storage.calculate_camera_bandwidth()
assert storage.camera_storage_stats == {
"front_door": {"bandwidth": 1440, "needs_refresh": True},
"front_door": {
"bandwidth": 1440,
"bandwidth_by_stream": {STREAM_TYPE_MAIN: 1440},
"needs_refresh": True,
},
}
def test_camera_usages_split_by_stream_type(self):
"""Usage and bandwidth are reported per stream type."""
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock())
time_keep = datetime.datetime.now().timestamp()
_insert_mock_recording(
"1234567.frontdoor",
os.path.join(self.test_dir, "main.tmp"),
time_keep,
time_keep + 10,
seg_size=20,
seg_dur=10,
stream_type=STREAM_TYPE_MAIN,
)
_insert_mock_recording(
"1234568.frontdoor",
os.path.join(self.test_dir, "sub.tmp"),
time_keep,
time_keep + 10,
seg_size=2,
seg_dur=10,
stream_type=STREAM_TYPE_SUB,
)
storage.calculate_camera_bandwidth()
usages = storage.calculate_camera_usages()
assert usages["front_door"]["usage"] == 22
assert usages["front_door"]["bandwidth"] == 7920
assert usages["front_door"]["streams"] == {
STREAM_TYPE_MAIN: {"usage": 20, "bandwidth": 7200},
STREAM_TYPE_SUB: {"usage": 2, "bandwidth": 720},
}
def test_camera_usages_omits_streams_without_segments(self):
"""A camera with no sub segments reports no sub entry."""
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock())
time_keep = datetime.datetime.now().timestamp()
_insert_mock_recording(
"1234567.frontdoor",
os.path.join(self.test_dir, "main.tmp"),
time_keep,
time_keep + 10,
seg_size=20,
seg_dur=10,
)
storage.calculate_camera_bandwidth()
usages = storage.calculate_camera_usages()
assert usages["front_door"]["usage"] == 20
assert usages["front_door"]["streams"] == {
STREAM_TYPE_MAIN: {"usage": 20, "bandwidth": 7200},
}
def test_camera_bandwidth_clamp_scales_stream_values(self):
"""Clamping the total keeps the per stream values summing to it."""
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock())
time_keep = datetime.datetime.now().timestamp()
_insert_mock_recording(
"1234567.frontdoor",
os.path.join(self.test_dir, "main.tmp"),
time_keep,
time_keep + 10,
seg_size=40,
seg_dur=10,
stream_type=STREAM_TYPE_MAIN,
)
_insert_mock_recording(
"1234568.frontdoor",
os.path.join(self.test_dir, "sub.tmp"),
time_keep,
time_keep + 10,
seg_size=4,
seg_dur=10,
stream_type=STREAM_TYPE_SUB,
)
storage.calculate_camera_bandwidth()
stats = storage.camera_storage_stats["front_door"]
assert stats["bandwidth"] == MAX_CALCULATED_BANDWIDTH
assert (
round(sum(stats["bandwidth_by_stream"].values()), 2)
== 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)
storage = StorageMaintainer(config, MagicMock())
storage.calculate_camera_bandwidth()
usages = storage.calculate_camera_usages()
assert usages["front_door"]["usage"] == 0
assert usages["front_door"]["streams"] == {}
def test_storage_cleanup(self):
"""Ensure that all recordings are cleaned up when necessary."""
config = FrigateConfig(**self.minimal_config)
@@ -332,6 +495,7 @@ def _insert_mock_recording(
camera="front_door",
seg_size=8,
seg_dur=10,
stream_type=STREAM_TYPE_MAIN,
) -> Event:
"""Inserts a basic recording model with a given id."""
# we must open the file so storage maintainer will delete it
@@ -348,4 +512,5 @@ def _insert_mock_recording(
motion=True,
objects=True,
segment_size=seg_size,
stream_type=stream_type,
).execute()
@@ -31,6 +31,10 @@
"label": "Listen types",
"description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell)."
},
"labelmap": {
"label": "Audio labelmap customization",
"description": "Overrides or remapping entries to merge into the standard audio labelmap."
},
"filters": {
"label": "Audio filters",
"description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.",
+4
View File
@@ -533,6 +533,10 @@
"label": "Listen types",
"description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell)."
},
"labelmap": {
"label": "Audio labelmap customization",
"description": "Overrides or remapping entries to merge into the standard audio labelmap."
},
"filters": {
"label": "Audio filters",
"description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.",
+4
View File
@@ -148,6 +148,10 @@
"unused": {
"title": "Unused",
"tips": "This value may not accurately represent the free space available to Frigate if you have other files stored on your drive beyond Frigate's recordings. Frigate does not track storage usage outside of its recordings."
},
"subStream": {
"information": "Sub Stream Storage Information",
"tips": "Cameras that record a sub stream show their Original and Low quality usage separately, using the same names as the quality options in recording playback. Low quality recordings stay on disk until their retention period expires, so this breakdown can still appear for a camera after sub stream recording has been turned off."
}
}
},
@@ -31,7 +31,7 @@ const audio: SectionConfigOverrides = {
detection: ["listen", "filters"],
sensitivity: ["min_volume", "max_not_heard"],
},
hiddenFields: ["enabled_in_config"],
hiddenFields: ["enabled_in_config", "labelmap"],
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
uiSchema: {
filters: {
+163 -80
View File
@@ -16,24 +16,28 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { getUnitSize } from "@/utils/storageUtil";
import { CameraStorage, StreamStorage } from "@/types/stats";
import { CiCircleAlert } from "react-icons/ci";
import { useTranslation } from "react-i18next";
type CameraStorage = {
[key: string]: {
bandwidth: number;
usage: number;
usage_percent: number;
};
};
type TotalStorage = {
used: number;
camera: number;
total: number;
};
type StorageRow = {
name: string;
usage: number;
bandwidth: number;
color: string;
streams?: {
main?: StreamStorage;
sub?: StreamStorage;
};
};
type CombinedStorageGraphProps = {
graphId: string;
cameraStorage: CameraStorage;
@@ -44,46 +48,59 @@ export function CombinedStorageGraph({
cameraStorage,
totalStorage,
}: CombinedStorageGraphProps) {
const { t } = useTranslation(["views/system"]);
const { t } = useTranslation(["views/system", "components/player"]);
const { theme, systemTheme } = useTheme();
const isDark = (systemTheme || theme) == "dark";
const entities = Object.keys(cameraStorage);
const colors = generateColors(entities.length);
const rows: StorageRow[] = useMemo(() => {
const entities = Object.keys(cameraStorage);
const colors = generateColors(entities.length);
const series = entities.map((entity, index) => ({
name: entity,
data: [(cameraStorage[entity].usage / totalStorage.total) * 100],
usage: cameraStorage[entity].usage,
bandwidth: cameraStorage[entity].bandwidth,
color: colors[index], // Assign the corresponding color
}));
return [
...entities.map((entity, index) => ({
name: entity,
usage: cameraStorage[entity].usage ?? 0,
bandwidth: cameraStorage[entity].bandwidth,
color: colors[index],
streams: cameraStorage[entity].streams,
})),
{
name: "Other",
usage: totalStorage.used - totalStorage.camera,
bandwidth: 0,
color: isDark ? "#606060" : "#D5D5D5",
},
{
name: "Unused",
usage: totalStorage.total - totalStorage.used,
bandwidth: 0,
color: isDark ? "#404040" : "#E5E5E5",
},
];
}, [cameraStorage, totalStorage, isDark]);
// Add the unused percentage to the series
series.push({
name: "Other",
data: [
((totalStorage.used - totalStorage.camera) / totalStorage.total) * 100,
],
usage: totalStorage.used - totalStorage.camera,
bandwidth: 0,
color: (systemTheme || theme) == "dark" ? "#606060" : "#D5D5D5",
});
series.push({
name: "Unused",
data: [
((totalStorage.total - totalStorage.used) / totalStorage.total) * 100,
],
usage: totalStorage.total - totalStorage.used,
bandwidth: 0,
color: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
});
const series = useMemo(
() =>
rows.map((row) => ({
name: row.name,
data: [(row.usage / totalStorage.total) * 100],
usage: row.usage,
color: row.color,
})),
[rows, totalStorage.total],
);
const hasSubStorage = useMemo(
() => rows.some((row) => row.streams?.sub),
[rows],
);
const options = useMemo(() => {
return {
chart: {
id: graphId,
background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
background: isDark ? "#404040" : "#E5E5E5",
selection: {
enabled: false,
},
@@ -164,7 +181,7 @@ export function CombinedStorageGraph({
max: 100,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme, series]);
}, [graphId, isDark, systemTheme, theme, series]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
@@ -185,6 +202,37 @@ export function CombinedStorageGraph({
[t],
);
const getStreamSplit = useCallback(
(row: StorageRow, field: keyof StreamStorage) => {
const mainValue = row.streams?.main?.[field];
const subValue = row.streams?.sub?.[field];
// 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;
}
return (
<div className="text-xs text-primary-variant">
<div>
<span className="text-muted-foreground">
{t("quality.main", { ns: "components/player" })}
</span>{" "}
{getUnitSize(mainValue)}
</div>
<div>
<span className="text-muted-foreground">
{t("quality.sub", { ns: "components/player" })}
</span>{" "}
{getUnitSize(subValue)}
</div>
</div>
);
},
[t],
);
return (
<div className="flex w-full flex-col gap-2.5">
<div className="flex w-full items-center justify-between gap-1">
@@ -206,7 +254,35 @@ export function CombinedStorageGraph({
<TableHeader>
<TableRow>
<TableHead>{t("storage.cameraStorage.camera")}</TableHead>
<TableHead>{t("storage.cameraStorage.storageUsed")}</TableHead>
<TableHead>
<div className="flex flex-row items-center gap-1">
{t("storage.cameraStorage.storageUsed")}
{hasSubStorage && (
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label={t(
"storage.cameraStorage.subStream.information",
)}
>
<CiCircleAlert
className="size-5"
aria-label={t(
"storage.cameraStorage.subStream.information",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
{t("storage.cameraStorage.subStream.tips")}
</div>
</PopoverContent>
</Popover>
)}
</div>
</TableHead>
<TableHead>
{t("storage.cameraStorage.percentageOfTotalUsed")}
</TableHead>
@@ -214,49 +290,56 @@ export function CombinedStorageGraph({
</TableRow>
</TableHeader>
<TableBody>
{series.map((item) => (
<TableRow key={item.name}>
<TableCell className="flex flex-row items-center gap-2 font-medium smart-capitalize">
{" "}
<div
className="size-3 rounded-md"
style={{ backgroundColor: item.color }}
></div>
{getItemTitle(item.name)}
{(item.name === "Unused" || item.name == "Other") && (
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
>
<CiCircleAlert
className="size-5"
{rows.map((row) => {
const isAggregate = row.name == "Unused" || row.name == "Other";
return (
<TableRow key={row.name}>
<TableCell className="flex flex-row items-center gap-2 font-medium smart-capitalize">
<div
className="size-3 rounded-md"
style={{ backgroundColor: row.color }}
></div>
{getItemTitle(row.name)}
{isAggregate && (
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
{t("storage.cameraStorage.unused.tips")}
</div>
</PopoverContent>
</Popover>
)}
</TableCell>
<TableCell>{getUnitSize(item.usage ?? 0)}</TableCell>
<TableCell>{item.data[0].toFixed(2)}%</TableCell>
<TableCell>
{item.name === "Unused" || item.name == "Other"
? "—"
: `${getUnitSize(item.bandwidth)} / hour`}
</TableCell>
</TableRow>
))}
>
<CiCircleAlert
className="size-5"
aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
{t("storage.cameraStorage.unused.tips")}
</div>
</PopoverContent>
</Popover>
)}
</TableCell>
<TableCell>
{getUnitSize(row.usage)}
{getStreamSplit(row, "usage")}
</TableCell>
<TableCell>
{((row.usage / totalStorage.total) * 100).toFixed(2)}%
</TableCell>
<TableCell>
{isAggregate ? "—" : `${getUnitSize(row.bandwidth)} / hour`}
{getStreamSplit(row, "bandwidth")}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
+18
View File
@@ -101,6 +101,24 @@ export type StorageStats = {
shm_frame_count?: number;
};
export type StreamStorage = {
usage: number;
bandwidth: number | null;
};
export type CameraStorage = {
[camera: string]: {
bandwidth: number;
usage: number;
usage_percent: number;
// absent for stream types with no segments on disk
streams?: {
main?: StreamStorage;
sub?: StreamStorage;
};
};
};
export type PotentialProblem = {
text: string;
color: string;
+1 -9
View File
@@ -1,6 +1,6 @@
import { CombinedStorageGraph } from "@/components/graph/CombinedStorageGraph";
import { StorageGraph } from "@/components/graph/StorageGraph";
import { FrigateStats } from "@/types/stats";
import { CameraStorage, FrigateStats } from "@/types/stats";
import { useEffect, useMemo } from "react";
import {
Popover,
@@ -24,14 +24,6 @@ import { LuExternalLink } from "react-icons/lu";
import { FaExclamationTriangle } from "react-icons/fa";
import ActivityIndicator from "@/components/indicators/activity-indicator";
type CameraStorage = {
[key: string]: {
bandwidth: number;
usage: number;
usage_percent: number;
};
};
type StorageMetricsProps = {
setLastUpdated: (last: number) => void;
};