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
This commit is contained in:
Josh Hawkins
2026-08-22 11:40:42 -05:00
parent af537b9479
commit 0f5ed8822d
7 changed files with 397 additions and 103 deletions
+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."
}
}
},
+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;
};